feat(game3): battle animation VM rendering architecture, full ROM extraction, evolution scene, and UI refinements

This commit is contained in:
1jamie
2026-09-10 20:12:29 -05:00
parent 0c55d1d32f
commit b06d8bc99c
44 changed files with 6686 additions and 1211 deletions
+1 -1
View File
@@ -1 +1 @@
{"schema_version":1,"mods":[]}
{"mods":[],"schema_version":1}
+3 -1
View File
@@ -209,9 +209,11 @@ function Audio.playSong(id, opts)
}
Audio._mapSong = Audio._mapSong or id
-- A new song owns the bus — cancel stale fades (oak exit fade was killing lab BGM).
-- A new song owns the bus — cancel stale fades and fanfares (oak exit fade was killing lab BGM).
Audio._fadeOut = nil
Audio._fadeIn = nil
Audio._fanfareActive = false
Audio._fanfareFrames = 0
Audio._bgmPaused = false
if not Audio.isReady() then
+6 -2
View File
@@ -40,6 +40,8 @@ local function default_present(side)
hFlip = false,
darken = 0,
scale = 1,
sx = 1,
sy = 1,
displayHp = nil,
displayMaxHp = nil,
displayExp = nil,
@@ -163,6 +165,8 @@ function Anim.faintMon(side, opts)
local p = Anim.present(side)
local stage = Anim.stage()
local hb = stage and stage.healthbox and stage.healthbox[side]
local AnimSprites = require("src.core.game3.battle.anim_sprites")
AnimSprites.clearHost(side)
local function hide_all()
if p then
@@ -532,9 +536,9 @@ function Anim.update(dt)
pump_status_queue()
end
function Anim.drawParticles()
function Anim.drawParticles(minZ, maxZ)
if Anim._headless then return end
if Anim._vm then Anim._vm:draw() end
if Anim._vm then Anim._vm:draw(minZ, maxZ) end
end
return Anim
+733 -23
View File
@@ -13,7 +13,6 @@ function AnimCallbacks.HitSplatBasic(sprite)
sprite.data[0] = (sprite.data[0] or 0) + 1
local life = sprite.data[0]
local u = math.min(1, life / 8)
-- Affine-ish: start small, settle
local sc = 0.55 + 0.55 * u
if life > 10 then
sc = sc * (1 - (life - 10) / 6)
@@ -25,59 +24,766 @@ function AnimCallbacks.HitSplatBasic(sprite)
destroy(sprite)
end
end
AnimCallbacks.CrossImpact = AnimCallbacks.HitSplatBasic
AnimCallbacks.FlashingHitSplat = AnimCallbacks.HitSplatBasic
AnimCallbacks.HitSplatPersistent = AnimCallbacks.HitSplatBasic
--- pret AnimCuttingSlice + AnimSlice_Step (Cut, Fury Cutter, Air Cutter).
-- args[1]=dx (40), args[2]=dy (-32), args[3]=dir (0=R to L, 1=L to R).
function AnimCallbacks.CuttingSlice(sprite)
if not sprite._inited then
sprite._inited = true
local dir = tonumber(sprite.data[2]) or 0
sprite.data[0] = 0 -- frame step counter
sprite.data[1] = -0x400 -- vx_fp (-4.0 px/frame)
sprite.data[2] = 0x400 -- vy_fp (+4.0 px/frame)
sprite.data[3] = 0 -- x offset accumulator (fp)
sprite.data[4] = 0 -- y offset accumulator (fp)
sprite.data[5] = dir
if dir == 1 then
sprite.data[1] = -sprite.data[1]
sprite.hFlip = true
else
sprite.hFlip = false
end
end
-- AnimSlice_Step
sprite.data[3] = (sprite.data[3] or 0) + (sprite.data[1] or 0)
sprite.data[4] = (sprite.data[4] or 0) + (sprite.data[2] or 0)
local dir = sprite.data[5] or 0
if dir == 0 then
sprite.data[1] = sprite.data[1] + 0x18
else
sprite.data[1] = sprite.data[1] - 0x18
end
sprite.data[2] = sprite.data[2] - 0x18
sprite.ox = math.floor((sprite.data[3] or 0) / 256)
sprite.oy = math.floor((sprite.data[4] or 0) / 256)
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
-- AnimCmds: 4 frames, 5 ticks per frame (y cell 0, 32, 64, 96)
local frame = math.min(3, math.floor((step - 1) / 5))
sprite.quadY = frame * (sprite._baseH or 32)
if step >= 20 then
destroy(sprite)
end
end
AnimCallbacks.AirCutterSlice = AnimCallbacks.CuttingSlice
--- pret AnimSlashSlice / AnimClawSlash / AnimFurySwipes (Slash, Scratch, Claw, False Swipe).
function AnimCallbacks.SlashSlice(sprite)
if not sprite._inited then
sprite._inited = true
sprite.data[0] = 0
end
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
local frame = math.min(3, math.floor((step - 1) / 4))
sprite.quadY = frame * (sprite._baseH or 32)
if step >= 16 then
destroy(sprite)
end
end
AnimCallbacks.ClawSlash = AnimCallbacks.SlashSlice
AnimCallbacks.FalseSwipeSlice = AnimCallbacks.SlashSlice
AnimCallbacks.FalseSwipePositionedSlice = AnimCallbacks.SlashSlice
AnimCallbacks.FurySwipes = AnimCallbacks.SlashSlice
AnimCallbacks.RevengeScratch = AnimCallbacks.SlashSlice
--- pret AnimBite / AnimFang / AnimSuperFang (Bite, Crunch, Super Fang).
function AnimCallbacks.Bite(sprite)
if not sprite._inited then
sprite._inited = true
sprite.data[0] = 0
end
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
local frame = math.min(3, math.floor((step - 1) / 4))
sprite.quadY = frame * (sprite._baseH or 32)
if step >= 16 then
destroy(sprite)
end
end
AnimCallbacks.Fang = AnimCallbacks.Bite
AnimCallbacks.SuperFang = AnimCallbacks.Bite
--- pret AnimRoarNoiseLine — noise arcs from attacker (Growl/Roar).
-- Sheet: 32x128 = four 32x32 cells. AnimCmds use tile indices 0/16/32/48
-- → cell rows 0,1,2,3. Anim 0 (diag) = cells 0↔1; anim 1 (horiz) = cells 2↔3.
-- data[0]=vx_fp, data[1]=vy_fp, data[2]=dir, data[3]=animBank,
-- data[4]=animTimer, data[5]=life, data[6]/[7]=pos accum
-- arg 0: initial x pixel offset
-- arg 1: initial y pixel offset
-- arg 2: direction (0 = upward, 1 = downward, 2 = horizontal)
function AnimCallbacks.RoarNoiseLine(sprite)
if not sprite._inited then
sprite._inited = true
local dir = tonumber(sprite.data[2]) or 0
local vx, vy = 0x280, 0
local animBank = 0 -- cells 0/1
local isOpponent = sprite._reversed and true or false
local vx = 0x280
local vy = 0
local animBank = 0
if dir == 0 then
vx, vy = 0x280, -0x280
vx = 0x280
vy = -0x280
sprite.vFlip = false
elseif dir == 1 then
vx, vy = 0x280, 0x280
vx = 0x280
vy = 0x280
sprite.vFlip = true
else
-- StartSpriteAnim(sprite, 1) — horizontal zigzag cells
animBank = 1
vx = 0x280
vy = 0
sprite.vFlip = false
end
-- pret: opponent-side attacker flips travel + hFlip (vm.isReversed)
if sprite._reversed then
if isOpponent then
vx = -vx
sprite.hFlip = true
else
sprite.hFlip = false
end
sprite.data[0] = vx
sprite.data[1] = vy
sprite.data[3] = animBank
sprite.data[4] = 0 -- anim timer
sprite.data[5] = 0 -- life
sprite.data[5] = 0
sprite.data[6] = 0
sprite.data[7] = 0
sprite.quadX = 0
sprite.quadY = animBank * 64 -- cell 0 or 2
end
-- ANIMCMD_FRAME(n, 3) ping-pong between bank cells every 3 frames
local life = (sprite.data[5] or 0)
local bank = sprite.data[3] or 0
local phase = math.floor(life / 3) % 2
sprite.quadY = bank * 64 + phase * 32
local step = (sprite.data[5] or 0) + 1
sprite.data[5] = step
sprite.data[6] = (sprite.data[6] or 0) + (sprite.data[0] or 0)
sprite.data[7] = (sprite.data[7] or 0) + (sprite.data[1] or 0)
sprite.ox = math.floor((sprite.data[6] or 0) / 256)
sprite.oy = math.floor((sprite.data[7] or 0) / 256)
sprite.data[5] = life + 1
if sprite.data[5] >= 14 then
local bank = sprite.data[3] or 0
local phase = math.floor((step - 1) / 3) % 2
local cell = (bank == 0) and phase or (2 + phase)
sprite.quadY = cell * (sprite._baseH or 32)
if step >= 14 then
destroy(sprite)
end
end
--- Projectile trajectory (BulletSeed, WaterBubbleProjectile, etc.).
function AnimCallbacks.ThrowProjectile(sprite)
if not sprite._inited then
sprite._inited = true
sprite.data[0] = 0
sprite.data[5] = 16
end
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
local dur = sprite.data[5] or 16
local u = math.min(1, step / dur)
local h = math.sin(u * math.pi) * 20
sprite.ox = math.floor((sprite._dx or 0) * u)
sprite.oy = math.floor((sprite._dy or 0) * u - h)
if step >= dur then
destroy(sprite)
end
end
AnimCallbacks.BulletSeed = AnimCallbacks.ThrowProjectile
AnimCallbacks.WaterBubbleProjectile = AnimCallbacks.ThrowProjectile
AnimCallbacks.SludgeProjectile = AnimCallbacks.ThrowProjectile
AnimCallbacks.BoneHitProjectile = AnimCallbacks.ThrowProjectile
--- pret AnimSpriteOnMonPos — plays sprite sheet frames centered on mon position
function AnimCallbacks.SpriteOnMonPos(sprite)
if sprite.tag == "ECLIPSING_ORB" then
return AnimCallbacks.EclipsingOrb(sprite)
end
if not sprite._inited then
sprite._inited = true
sprite.data[0] = 0
end
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
local frame = math.min(totalFrames - 1, math.floor((step - 1) / 3))
sprite.quadY = frame * cellH
if step >= totalFrames * 3 then
destroy(sprite)
end
end
--- pret sEclipsingOrbAnimCmds (Defense Curl orb bubble expansion/contraction).
function AnimCallbacks.EclipsingOrb(sprite)
if not sprite._inited then
sprite._inited = true
sprite.data[0] = 0
end
local step = (sprite.data[0] or 0) + 1
sprite.data[0] = step
local frameSeq = {
{ cell = 0, hFlip = false },
{ cell = 1, hFlip = false },
{ cell = 2, hFlip = false },
{ cell = 3, hFlip = false },
{ cell = 2, hFlip = true },
{ cell = 1, hFlip = true },
{ cell = 0, hFlip = true },
}
local cycleTick = (step - 1) % 21
local idx = math.min(7, math.floor(cycleTick / 3) + 1)
local f = frameSeq[idx] or frameSeq[1]
sprite.quadY = f.cell * (sprite._baseH or 32)
sprite.hFlip = f.hFlip
if step >= 42 then
destroy(sprite)
end
end
--- pret AnimMovePowderParticle (PoisonPowder, StunSpore, SleepPowder, CottonSpore).
-- Sprites fall downwards with sinusoidal sway onto the target Pokémon.
function AnimCallbacks.MovePowderParticle(sprite)
if not sprite._inited then
sprite._inited = true
local args = sprite._args or {}
sprite._dur = tonumber(args[3]) or 80
sprite._vy = (tonumber(args[4]) or 80) / 256
local amp = tonumber(args[5]) or 5
if sprite._reversed then amp = -amp end
sprite._amp = amp
sprite._speed = tonumber(args[6]) or 1
sprite._phase = 0
sprite._yAccum = 0
sprite._step = 0
end
sprite._step = sprite._step + 1
sprite._yAccum = sprite._yAccum + sprite._vy
sprite.oy = math.floor(sprite._yAccum)
sprite._phase = (sprite._phase + sprite._speed) % 256
sprite.ox = math.floor(math.sin(sprite._phase * 2 * math.pi / 256) * sprite._amp)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
--- pret AnimAbsorptionOrb (Absorb, Mega Drain, Giga Drain, Leech Life).
-- Energy orbs travel from target to attacker in an arc.
function AnimCallbacks.AbsorptionOrb(sprite)
if not sprite._inited then
sprite._inited = true
local args = sprite._args or {}
sprite._dur = math.max(12, tonumber(args[4]) or 20)
sprite._amp = tonumber(args[3]) or 16
sprite._step = 0
sprite._startX = sprite.x
sprite._startY = sprite.y
local ax, ay = sprite._attackerX or sprite.x, sprite._attackerY or sprite.y
sprite._dx = ax - sprite._startX
sprite._dy = ay - sprite._startY
end
sprite._step = sprite._step + 1
local u = math.min(1, sprite._step / sprite._dur)
local arc = math.sin(u * math.pi) * sprite._amp
sprite.ox = math.floor(sprite._dx * u)
sprite.oy = math.floor(sprite._dy * u - arc)
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.PowerAbsorptionOrb = AnimCallbacks.AbsorptionOrb
--- Linear / projectile translation to target mon location.
function AnimCallbacks.TranslateAnimSpriteToTargetMonLocation(sprite)
if not sprite._inited then
sprite._inited = true
local args = sprite._args or {}
sprite._dur = math.max(8, tonumber(args[5]) or 16)
sprite._step = 0
local tx, ty = sprite._targetX or sprite.x, sprite._targetY or sprite.y
sprite._dx = tx - sprite.x
sprite._dy = ty - sprite.y
end
sprite._step = sprite._step + 1
local u = math.min(1, sprite._step / sprite._dur)
sprite.ox = math.floor(sprite._dx * u)
sprite.oy = math.floor(sprite._dy * u)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.TranslateLinearSingleSineWave = AnimCallbacks.TranslateAnimSpriteToTargetMonLocation
AnimCallbacks.PainSplitProjectile = AnimCallbacks.TranslateAnimSpriteToTargetMonLocation
--- Floating / drifting particles (Petal Dance, Sweet Scent, Razor Leaf, Flying Particle).
function AnimCallbacks.FlyingParticle(sprite)
if not sprite._inited then
sprite._inited = true
sprite._dur = 32
sprite._step = 0
local args = sprite._args or {}
sprite._vx = (tonumber(args[3]) or 1) * (sprite._reversed and -1 or 1)
sprite._vy = tonumber(args[4]) or 1
end
sprite._step = sprite._step + 1
sprite.ox = (sprite.ox or 0) + sprite._vx
sprite.oy = (sprite.oy or 0) + sprite._vy
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 4) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.PetalDanceSmallFlower = AnimCallbacks.FlyingParticle
AnimCallbacks.PetalDanceBigFlower = AnimCallbacks.FlyingParticle
AnimCallbacks.SweetScentPetal = AnimCallbacks.FlyingParticle
AnimCallbacks.RazorLeafParticle = AnimCallbacks.FlyingParticle
AnimCallbacks.FallingFeather = AnimCallbacks.FlyingParticle
--- Falling rocks / projectiles (Rock Slide, Rock Tomb, Eruption).
function AnimCallbacks.FallingRock(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 20
sprite.oy = -60
end
sprite._step = sprite._step + 1
local u = math.min(1, sprite._step / sprite._dur)
sprite.oy = math.floor(-60 + 60 * (u * u))
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.EruptionFallingRock = AnimCallbacks.FallingRock
AnimCallbacks.RockFragment = AnimCallbacks.FallingRock
AnimCallbacks.RockTomb = AnimCallbacks.FallingRock
--- Flames rising and expanding (Ember, Flamethrower, Fire Blast, Outrage).
function AnimCallbacks.LargeFlame(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 24
end
sprite._step = sprite._step + 1
sprite.oy = -(sprite._step * 0.75)
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.OutrageFlame = AnimCallbacks.LargeFlame
AnimCallbacks.OverheatFlame = AnimCallbacks.LargeFlame
AnimCallbacks.DragonFireToTarget = AnimCallbacks.LargeFlame
AnimCallbacks.DragonRageFirePlume = AnimCallbacks.LargeFlame
AnimCallbacks.FireSpiralInward = AnimCallbacks.LargeFlame
--- Sparkling stars & twinkle particles (Wish, Swift, Moonlight, Morning Sun).
function AnimCallbacks.GrantingStars(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 20
end
sprite._step = sprite._step + 1
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.SparklingStars = AnimCallbacks.GrantingStars
AnimCallbacks.EyeSparkle = AnimCallbacks.GrantingStars
AnimCallbacks.WallSparkle = AnimCallbacks.GrantingStars
AnimCallbacks.MoonlightSparkle = AnimCallbacks.GrantingStars
--- Status / Emote particles (Confuse Duck, Hearts, Tears, Alert, Anger).
function AnimCallbacks.DizzyPunchDuck(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 36
end
sprite._step = sprite._step + 1
local angle = (sprite._step / 36) * math.pi * 4
sprite.ox = math.floor(math.cos(angle) * 16)
sprite.oy = math.floor(math.sin(angle) * 6 - 8)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.AngerMark = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.TealAlert = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.TearDrop = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.PinkHeart = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.RedHeartRising = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.RedHeartProjectile = AnimCallbacks.TranslateAnimSpriteToTargetMonLocation
--- Swirling vortex & orbiting debris (Twister, Whirlpool, Sandstorm, Fire Spin).
function AnimCallbacks.ParticleInVortex(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 32
sprite._radius = 24
sprite._angle = (tonumber(sprite.data[1]) or 0) * (math.pi / 4)
end
sprite._step = sprite._step + 1
sprite._angle = sprite._angle + 0.22
sprite.rotation = sprite._angle
local r = sprite._radius * (1.0 - (sprite._step / sprite._dur) * 0.4)
sprite.ox = math.floor(math.cos(sprite._angle) * r + 0.5)
sprite.oy = math.floor(math.sin(sprite._angle) * (r * 0.45) - (sprite._step * 0.6) + 0.5)
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.MoveTwisterParticle = AnimCallbacks.ParticleInVortex
AnimCallbacks.WhirlwindLine = AnimCallbacks.ParticleInVortex
AnimCallbacks.FlyingSandCrescent = AnimCallbacks.ParticleInVortex
AnimCallbacks.OrbitFast = AnimCallbacks.ParticleInVortex
AnimCallbacks.OrbitScatter = AnimCallbacks.ParticleInVortex
--- Energy orbs with pulsing affine scale and rotation (Dragon Dance, Meteor Mash, Power Orbs).
function AnimCallbacks.DragonDanceOrb(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 28
end
sprite._step = sprite._step + 1
sprite.rotation = sprite.rotation + 0.15
local pulse = 1.0 + math.sin((sprite._step / 28) * math.pi * 3) * 0.25
sprite.scaleX = pulse
sprite.scaleY = pulse
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.MeteorMashStar = AnimCallbacks.DragonDanceOrb
AnimCallbacks.ReversalOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SpitUpOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SwallowBlueOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SuperpowerOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SuperpowerRock = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SuperpowerFireball = AnimCallbacks.DragonDanceOrb
AnimCallbacks.EndureEnergy = AnimCallbacks.DragonDanceOrb
AnimCallbacks.TailGlowOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.ThunderboltOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.GrowingChargeOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.GrowingShockWaveOrb = AnimCallbacks.DragonDanceOrb
AnimCallbacks.SharpenSphere = AnimCallbacks.DragonDanceOrb
AnimCallbacks.TriAttackTriangle = AnimCallbacks.DragonDanceOrb
--- Projectiles & multi-hit stingers (Pin Missile, Twineedle, Spike Cannon, Poison Sting).
function AnimCallbacks.TranslateStinger(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 16
local dx = sprite._dx or (sprite._reversed and -80 or 80)
local dy = sprite._dy or (sprite._reversed and 40 or -40)
sprite.rotation = math.atan2(dy, dx)
end
sprite._step = sprite._step + 1
local progress = sprite._step / sprite._dur
sprite.ox = math.floor((sprite._dx or 0) * progress + 0.5)
sprite.oy = math.floor((sprite._dy or 0) * progress + 0.5)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.BonemerangProjectile = AnimCallbacks.TranslateStinger
AnimCallbacks.SonicBoomProjectile = AnimCallbacks.TranslateStinger
AnimCallbacks.RockBlastRock = AnimCallbacks.TranslateStinger
AnimCallbacks.LeechLifeNeedle = AnimCallbacks.TranslateStinger
AnimCallbacks.ThrowMistBall = AnimCallbacks.TranslateStinger
--- Ice Beam / Blizzard crystal streams.
function AnimCallbacks.IceBeamParticle(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 18
end
sprite._step = sprite._step + 1
sprite.rotation = sprite.rotation + 0.2
local progress = sprite._step / sprite._dur
sprite.ox = math.floor((sprite._dx or 0) * progress + 0.5)
sprite.oy = math.floor((sprite._dy or 0) * progress + 0.5)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.IcePunchSwirlingParticle = AnimCallbacks.ParticleInVortex
--- Sludge Bomb / Acid / Poison Gas particles.
function AnimCallbacks.SludgeBombHitParticle(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 20
sprite._vx = (math.random() - 0.5) * 2.5
sprite._vy = -math.random() * 2.0
end
sprite._step = sprite._step + 1
sprite._vy = sprite._vy + 0.18 -- gravity
sprite.ox = math.floor((sprite.ox or 0) + sprite._vx + 0.5)
sprite.oy = math.floor((sprite.oy or 0) + sprite._vy + 0.5)
local cellH = sprite._baseH or 32
local totalFrames = 1
if sprite.image and sprite.image.getDimensions then
local _, ih = sprite.image:getDimensions()
totalFrames = math.max(1, math.floor(ih / cellH))
end
sprite.quadY = (math.floor((sprite._step - 1) / 3) % totalFrames) * cellH
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.AcidPoisonDroplet = AnimCallbacks.SludgeBombHitParticle
AnimCallbacks.AcidPoisonBubble = AnimCallbacks.SludgeBombHitParticle
AnimCallbacks.InitPoisonGasCloudAnim = AnimCallbacks.SludgeBombHitParticle
--- Radial particle explosion (Explosion, Self-Destruct, Swift burst).
function AnimCallbacks.ParticleBurst(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 24
local angle = math.random() * math.pi * 2
local speed = 1.5 + math.random() * 2.0
sprite._vx = math.cos(angle) * speed
sprite._vy = math.sin(angle) * speed
end
sprite._step = sprite._step + 1
sprite.ox = math.floor((sprite.ox or 0) + sprite._vx + 0.5)
sprite.oy = math.floor((sprite.oy or 0) + sprite._vy + 0.5)
sprite.alpha = math.max(0, 1.0 - (sprite._step / sprite._dur))
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
--- Additive energy shields & barriers (Reflect, Light Screen, Barrier, Protect).
function AnimCallbacks.DefensiveWall(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 36
sprite.blendMode = "add"
end
sprite._step = sprite._step + 1
local pulse = 0.6 + math.sin((sprite._step / 36) * math.pi * 4) * 0.35
sprite.alpha = pulse
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.GuardRing = AnimCallbacks.DefensiveWall
AnimCallbacks.BlendThinRing = AnimCallbacks.DefensiveWall
AnimCallbacks.Protect = AnimCallbacks.DefensiveWall
AnimCallbacks.WhiteHalo = AnimCallbacks.DefensiveWall
--- Barrier overlays & entanglements (Block, Disable, Spikes, Web).
AnimCallbacks.BlockX = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.RedX = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.SpiderWeb = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Spikes = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.StringWrap = AnimCallbacks.SpriteOnMonPos
--- Musical notes & sound waves (Sing, Heal Bell, Perish Song, Growl, Roar, Screech, Snore).
function AnimCallbacks.HealBellMusicNote(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 32
sprite._freq = 0.15 + (tonumber(sprite.data[1]) or 0) * 0.05
end
sprite._step = sprite._step + 1
sprite.oy = -(sprite._step * 0.9)
sprite.ox = math.floor(math.sin(sprite._step * sprite._freq) * 8 + 0.5)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.PerishSongMusicNote = AnimCallbacks.HealBellMusicNote
AnimCallbacks.PerishSongMusicNote2 = AnimCallbacks.HealBellMusicNote
AnimCallbacks.FlyingMusicNotes = AnimCallbacks.HealBellMusicNote
AnimCallbacks.SlowFlyingMusicNotes = AnimCallbacks.HealBellMusicNote
AnimCallbacks.JaggedMusicNote = AnimCallbacks.HealBellMusicNote
AnimCallbacks.UproarRing = AnimCallbacks.DefensiveWall
--- Snooze Z's and smoke puffs (Rest, Yawn, Smokescreen).
function AnimCallbacks.SleepLetterZ(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 28
end
sprite._step = sprite._step + 1
sprite.oy = -(sprite._step * 0.7)
sprite.ox = math.floor(math.sin(sprite._step * 0.2) * 6 + 0.5)
sprite.alpha = math.max(0, 1.0 - (sprite._step / sprite._dur) * 0.7)
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.LetterZ = AnimCallbacks.SleepLetterZ
AnimCallbacks.YawnCloud = AnimCallbacks.SleepLetterZ
AnimCallbacks.BlackSmoke = AnimCallbacks.SleepLetterZ
AnimCallbacks.BreathPuff = AnimCallbacks.SleepLetterZ
AnimCallbacks.MovementWaves = AnimCallbacks.SpriteOnMonPos
--- Combat emotes and props (Metronome, Clapping, Fingers, Spoons, Eyes).
function AnimCallbacks.MetronomeFinger(sprite)
if not sprite._inited then
sprite._inited = true
sprite._step = 0
sprite._dur = 36
end
sprite._step = sprite._step + 1
sprite.rotation = math.sin((sprite._step / 36) * math.pi * 6) * 0.35
if sprite._step >= sprite._dur then
destroy(sprite)
end
end
AnimCallbacks.FollowMeFinger = AnimCallbacks.MetronomeFinger
AnimCallbacks.TauntFinger = AnimCallbacks.MetronomeFinger
AnimCallbacks.BellyDrumHand = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.HelpingHandClap = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.SmellingSaltsHand = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.ClappingHand = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.ClappingHand2 = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.ForesightMagnifyingGlass = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.MeanLookEye = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.BentSpoon = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Pencil = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.ThoughtBubble = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.TrickBag = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.BatonPassPokeball = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Present = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Moon = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Angel = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Devil = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.QuestionMark = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.SmellingSaltExclamation = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.GreenStar = AnimCallbacks.GrantingStars
AnimCallbacks.WeakFrustrationAngerMark = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.KnockOffStrike = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.LockOnTarget = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.LockOnMoveTarget = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.MilkBottle = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Leer = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.FallingCoin = AnimCallbacks.FallingRock
AnimCallbacks.CoinThrow = AnimCallbacks.ThrowProjectile
AnimCallbacks.FlatterSpotlight = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.FlatterConfetti = AnimCallbacks.FlyingParticle
AnimCallbacks.PsychoBoost = AnimCallbacks.ParticleInVortex
AnimCallbacks.Spotlight = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Recycle = AnimCallbacks.ParticleInVortex
AnimCallbacks.SlideHandOrFootToTarget = AnimCallbacks.TranslateStinger
AnimCallbacks.GustToTarget = AnimCallbacks.TranslateStinger
AnimCallbacks.EllipticalGust = AnimCallbacks.ParticleInVortex
AnimCallbacks.FistOrFootRandomPos = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.SporeParticle = AnimCallbacks.MovePowderParticle
AnimCallbacks.TravelDiagonally = AnimCallbacks.TranslateStinger
AnimCallbacks.RapidSpin = AnimCallbacks.ParticleInVortex
AnimCallbacks.Lick = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Conversion = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.Conversion2 = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.RaiseSprite = AnimCallbacks.SpriteOnMonPos
AnimCallbacks.ComplexPaletteBlend = AnimCallbacks.SpriteOnMonPos
function AnimCallbacks.SimpleFadeOut(sprite)
sprite.data[0] = (sprite.data[0] or 0) + 1
local life = sprite.data[0]
@@ -87,6 +793,9 @@ end
--- noGfx helpers are handled as visual tasks, not sprites.
AnimCallbacks.HorizontalLunge = nil
AnimCallbacks.VerticalDip = nil
AnimCallbacks.SlideMonToOriginalPos = nil
AnimCallbacks.SlideMonToOffset = nil
function AnimCallbacks.get(name)
if not name then return AnimCallbacks.HitSplatBasic end
@@ -94,3 +803,4 @@ function AnimCallbacks.get(name)
end
return AnimCallbacks
+83 -9
View File
@@ -5,21 +5,65 @@ local AnimSprites = {}
AnimSprites.MAX = 128
AnimSprites.Z = {
BEHIND = 10,
MID = 30,
FRONT = 50,
GLOBAL_BEHIND = 10,
ENEMY_BEHIND = 90,
ENEMY_MON = 100,
ENEMY_FRONT = 110,
MID_FIELD = 150,
PLAYER_BEHIND = 190,
PLAYER_MON = 200,
PLAYER_FRONT = 210,
GLOBAL_FRONT = 900,
}
--- Slot-based dynamic Z calculation (supports 1v1 and 2v2 double battles).
function AnimSprites.slotZ(slot, layer)
local slotId = 1
if type(slot) == "number" then
slotId = slot
elseif slot == "player" or slot == "player_left" then
slotId = 2
elseif slot == "player_right" then
slotId = 4
elseif slot == "enemy_right" then
slotId = 3
else -- "enemy" or "enemy_left"
slotId = 1
end
if layer == "behind" then
return (slotId * 100) - 10
elseif layer == "mon" then
return (slotId * 100)
elseif layer == "front" then
return (slotId * 100) + 10
elseif layer == "global_behind" then
return 10
elseif layer == "global_front" then
return 900
end
return (slotId * 100) + 10
end
local function clear_slot(s)
s.active = false
s.x = 0
s.y = 0
s.ox = 0
s.oy = 0
s.z = AnimSprites.Z.MID
s.z = AnimSprites.Z.MID_FIELD
s.priority = 2
s.subpriority = 0
s.hostId = nil
s.alpha = 1
s.hFlip = false
s.vFlip = false
s.rotation = 0
s.scaleX = 1
s.scaleY = 1
s.originX = nil
s.originY = nil
s.blendMode = "alpha" -- "alpha" | "add"
s.visible = true
s.tag = nil
s.template = nil
@@ -69,6 +113,18 @@ function AnimSprites.reset()
AnimSprites._overflowLogged = false
end
--- Sweep and destroy any active particles bound to a fainted/switched host battler.
function AnimSprites.clearHost(hostId)
if not hostId then return end
AnimSprites.init()
for i = 1, AnimSprites.MAX do
local s = AnimSprites._pool[i]
if s.active and s.hostId == hostId then
clear_slot(s)
end
end
end
--- Acquire a free slot. Returns sprite or nil if pool exhausted.
function AnimSprites.acquire(opts)
AnimSprites.init()
@@ -80,7 +136,10 @@ function AnimSprites.acquire(opts)
s.active = true
s.x = opts.x or 0
s.y = opts.y or 0
s.z = opts.z or AnimSprites.Z.MID
s.z = opts.z or AnimSprites.Z.MID_FIELD
s.priority = opts.priority or 2
s.subpriority = opts.subpriority or 0
s.hostId = opts.hostId
s.tag = opts.tag
s.template = opts.template
s.image = opts.image
@@ -88,6 +147,13 @@ function AnimSprites.acquire(opts)
s.w = opts.w or 16
s.h = opts.h or 16
s.hFlip = opts.hFlip and true or false
s.vFlip = opts.vFlip and true or false
s.rotation = opts.rotation or 0
s.scaleX = opts.scaleX or 1
s.scaleY = opts.scaleY or 1
s.originX = opts.originX
s.originY = opts.originY
s.blendMode = opts.blendMode or "alpha"
s.callback = opts.callback
s.palSlot = opts.palSlot or 0
s.monoTint = opts.monoTint
@@ -142,19 +208,27 @@ function AnimSprites.update()
end
end
--- Collect active sprites sorted by z (stable by index).
function AnimSprites.sortedDrawList(out)
--- Collect active sprites sorted by z and subpriority, filtered by optional [minZ, maxZ] range.
function AnimSprites.sortedDrawList(out, minZ, maxZ)
out = out or {}
for i = #out, 1, -1 do out[i] = nil end
AnimSprites.init()
for i = 1, AnimSprites.MAX do
local s = AnimSprites._pool[i]
if s.active and s.visible then
out[#out + 1] = s
local z = s.z or AnimSprites.Z.MID_FIELD
if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then
out[#out + 1] = s
end
end
end
table.sort(out, function(a, b)
if a.z ~= b.z then return a.z < b.z end
local za = a.z or AnimSprites.Z.MID_FIELD
local zb = b.z or AnimSprites.Z.MID_FIELD
if za ~= zb then return za < zb end
local sa = a.subpriority or 0
local sb = b.subpriority or 0
if sa ~= sb then return sa < sb end
return false
end)
return out
+214
View File
@@ -164,6 +164,220 @@ AnimTasks.REGISTRY.AnimTask_BlendColorCycle = AnimTasks.BlendColorCycle
AnimTasks.REGISTRY.AnimTask_BlendColorCycleByTag = AnimTasks.BlendColorCycle
AnimTasks.REGISTRY.AnimTask_BlendColorCycleExclude = AnimTasks.BlendColorCycle
--- pret AnimTask_DefenseCurlDeformMon: squishes mon vertically/horizontally (2 cycles of 16 ticks = 32 ticks).
function AnimTasks.DefenseCurlDeformMon(t, vm)
local side = vm:attackerSide()
local Anim = require("src.core.game3.battle.anim")
local p = Anim.present(side)
if not p then
destroy_task(t)
return
end
local frame = t.data[14] or 0
t.data[14] = frame + 1
if frame < 32 then
local phase = (frame % 16) / 16 * math.pi * 2
local deform = math.sin(phase) * 0.22
p.sx = 1.0 - deform * 0.8
p.sy = 1.0 + deform
else
p.sx = 1.0
p.sy = 1.0
destroy_task(t)
end
end
AnimTasks.REGISTRY.DefenseCurlDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.AnimTask_DefenseCurlDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.StockpileDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.AnimTask_StockpileDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.SwallowDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.AnimTask_SwallowDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.SpitUpDeformMon = AnimTasks.DefenseCurlDeformMon
AnimTasks.REGISTRY.AnimTask_SpitUpDeformMon = AnimTasks.DefenseCurlDeformMon
--- pret AnimTask_SlideMon: slides mon along vector (dx, dy) towards target and returns.
function AnimTasks.SlideMon(t, vm)
local Anim = require("src.core.game3.battle.anim")
local side = vm:attackerSide()
local p = Anim.present(side)
if not p then
destroy_task(t)
return
end
local tx, ty = vm:battlerCenter(vm:resolveBattlerSide("target"))
local ax, ay = vm:battlerCenter(side)
local dx = (tx - ax) * 0.45
local dy = (ty - ay) * 0.45
local frame = t.data[14] or 0
t.data[14] = frame + 1
local totalFrames = 18
if frame < 8 then
local progress = frame / 8
p.ox = math.floor(dx * progress + 0.5)
p.oy = math.floor(dy * progress + 0.5)
elseif frame < 12 then
p.ox = math.floor(dx + 0.5)
p.oy = math.floor(dy + 0.5)
elseif frame < totalFrames then
local progress = 1.0 - ((frame - 12) / 6)
p.ox = math.floor(dx * progress + 0.5)
p.oy = math.floor(dy * progress + 0.5)
else
p.ox = 0
p.oy = 0
destroy_task(t)
end
end
AnimTasks.REGISTRY.SlideMon = AnimTasks.SlideMon
AnimTasks.REGISTRY.SlideMonToOffset = AnimTasks.SlideMon
AnimTasks.REGISTRY.SlideMonToOriginalPos = AnimTasks.SlideMon
AnimTasks.REGISTRY.AnimTask_SlideMonToOffset = AnimTasks.SlideMon
AnimTasks.REGISTRY.AnimTask_SlideMonToOriginalPos = AnimTasks.SlideMon
--- pret AnimTask_DarkenBattleAnimBg: dims background during signature VFX.
function AnimTasks.DarkenBattleAnimBg(t, _vm)
local Anim = require("src.core.game3.battle.anim")
local stage = Anim.stage and Anim.stage()
if not stage then
destroy_task(t)
return
end
local frame = t.data[14] or 0
t.data[14] = frame + 1
local dur = 32
if frame < 8 then
stage.bgDim = (frame / 8) * 0.85
elseif frame < 24 then
stage.bgDim = 0.85
elseif frame < dur then
stage.bgDim = (1.0 - (frame - 24) / 8) * 0.85
else
stage.bgDim = 0
destroy_task(t)
end
end
AnimTasks.REGISTRY.DarkenBattleAnimBg = AnimTasks.DarkenBattleAnimBg
AnimTasks.REGISTRY.AnimTask_DarkenBattleAnimBg = AnimTasks.DarkenBattleAnimBg
--- pret AnimTask_BowMon: squashes attacker vertically for meditation / calming.
function AnimTasks.BowMon(t, vm)
local Anim = require("src.core.game3.battle.anim")
local side = vm:attackerSide()
local p = Anim.present(side)
if not p then
destroy_task(t)
return
end
local frame = t.data[14] or 0
t.data[14] = frame + 1
if frame < 10 then
p.sy = 1.0 - (frame / 10) * 0.18
elseif frame < 20 then
p.sy = 0.82 + ((frame - 10) / 10) * 0.18
else
p.sy = 1.0
destroy_task(t)
end
end
AnimTasks.REGISTRY.BowMon = AnimTasks.BowMon
AnimTasks.REGISTRY.AnimTask_BowMon = AnimTasks.BowMon
--- pret AnimTask_ShakeMonOrBattleTerrain: screen / ground shake.
function AnimTasks.ShakeMonOrBattleTerrain(t, vm)
AnimTasks.ShakeMon(t, vm)
end
AnimTasks.REGISTRY.ShakeMonOrBattleTerrain = AnimTasks.ShakeMonOrBattleTerrain
AnimTasks.REGISTRY.AnimTask_ShakeMonOrBattleTerrain = AnimTasks.ShakeMonOrBattleTerrain
--- pret AnimTask_CreateSurfWave: creates surging wave of water across the battlefield.
function AnimTasks.CreateSurfWave(t, vm)
local frame = t.data[14] or 0
t.data[14] = frame + 1
local totalDur = 40
local atkSide = vm:attackerSide()
local tgtSide = vm:resolveBattlerSide("target")
local Anim = require("src.core.game3.battle.anim")
local pTgt = Anim.present(tgtSide)
local ax, ay = vm:battlerCenter(atkSide)
local tx, ty = vm:battlerCenter(tgtSide)
-- Spawn water particles periodically across the wave path
if frame < 28 and frame % 2 == 0 then
local u = frame / 28
local waveX = ax + (tx - ax) * u
local waveY = ay + (ty - ay) * u
local tags = { "WATER_ORB", "BUBBLE", "WATER_DROPLET", "WATER_IMPACT", "WATER_COLUMN" }
local pack = vm._pack
for _, tagName in ipairs(tags) do
local imgMeta = pack and pack.tags and pack.tags[tagName]
if imgMeta and imgMeta.image then
local rndX = waveX + math.random(-24, 24)
local rndY = waveY + math.random(-16, 16)
local spr = AnimSprites.acquire({
x = rndX,
y = rndY,
z = AnimSprites.Z.FRONT,
image = imgMeta.image,
w = imgMeta.frameW or 16,
h = imgMeta.frameH or 16,
hFlip = math.random() > 0.5,
tag = tagName,
callback = function(s)
s.data[0] = (s.data[0] or 0) + 1
local lf = s.data[0]
s.oy = (s.oy or 0) - 1.2
s.alpha = math.max(0, 1 - lf / 16)
if lf >= 16 then
AnimSprites.release(s)
end
end,
})
if spr then
spr._baseW = imgMeta.frameW or 16
spr._baseH = imgMeta.frameH or 16
end
break
end
end
end
-- Target shakes when wave hits
if frame >= 18 and frame < 36 and pTgt then
local phase = frame % 4
local amp = 4
pTgt.ox = (phase == 0 or phase == 3) and amp or -amp
pTgt.flash = frame
elseif frame >= 36 and pTgt then
pTgt.ox = 0
pTgt.flash = 0
end
if frame >= totalDur then
if pTgt then pTgt.ox = 0 pTgt.flash = 0 end
destroy_task(t)
end
end
AnimTasks.REGISTRY.CreateSurfWave = AnimTasks.CreateSurfWave
AnimTasks.REGISTRY.AnimTask_CreateSurfWave = AnimTasks.CreateSurfWave
function AnimTasks.SetGrayscaleOrOriginalPal(t, _vm)
destroy_task(t)
end
AnimTasks.REGISTRY.SetGrayscaleOrOriginalPal = AnimTasks.SetGrayscaleOrOriginalPal
AnimTasks.REGISTRY.AnimTask_SetGrayscaleOrOriginalPal = AnimTasks.SetGrayscaleOrOriginalPal
function AnimTasks.spawn(name, priority, args, vm)
AnimTasks.init()
name = tostring(name or "stub")
+143 -31
View File
@@ -307,29 +307,52 @@ local function sprite_source_quad(s, bw, bh)
return q
end
function AnimVm:draw()
function AnimVm:draw(minZ, maxZ)
if not (love and love.graphics) then return end
local list = AnimSprites.sortedDrawList(self._drawList)
local list = AnimSprites.sortedDrawList(self._drawList, minZ, maxZ)
local activeBlend = "alpha"
for _, s in ipairs(list) do
if s.image then
local x = s.x + (s.ox or 0)
local y = s.y + (s.oy or 0)
local rawX = s.x + (s.ox or 0)
local rawY = s.y + (s.oy or 0)
local drawX = math.floor(rawX + 0.5)
local drawY = math.floor(rawY + 0.5)
local a = s.alpha or 1
local desiredBlend = s.blendMode or "alpha"
if desiredBlend ~= activeBlend then
if desiredBlend == "add" then
love.graphics.setBlendMode("add", "alphamultiply")
else
love.graphics.setBlendMode("alpha", "alphamultiply")
end
activeBlend = desiredBlend
end
love.graphics.setColor(1, 1, 1, a)
local sx = s.hFlip and -1 or 1
local sy = s.vFlip and -1 or 1
local flipX = s.hFlip and -1 or 1
local flipY = s.vFlip and -1 or 1
local bw = s._baseW or s.w or 32
local bh = s._baseH or s.h or 32
local dw = s.w or bw
local dh = s.h or bh
local scaleX = (s.scaleX or 1) * flipX
local scaleY = (s.scaleY or 1) * flipY
local rot = s.rotation or 0
local pivX = s.originX or (bw / 2)
local pivY = s.originY or (bh / 2)
local q = sprite_source_quad(s, bw, bh)
if q then
love.graphics.draw(s.image, q, x, y, 0, sx * (dw / bw), sy * (dh / bh), bw / 2, bh / 2)
love.graphics.draw(s.image, q, drawX, drawY, rot, scaleX, scaleY, pivX, pivY)
else
love.graphics.draw(s.image, x, y, 0, sx * (dw / bw), sy * (dh / bh), bw / 2, bh / 2)
love.graphics.draw(s.image, drawX, drawY, rot, scaleX, scaleY, pivX, pivY)
end
end
end
if activeBlend ~= "alpha" then
love.graphics.setBlendMode("alpha", "alphamultiply")
end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -354,14 +377,19 @@ local function run_createsprite(vm, op)
local noGfx = op.noGfx or (info and info.noGfx)
-- Invisible helper templates → visual tasks (pret sprite CB moves battler)
if noGfx or cbName == "HorizontalLunge" or cbName == "VerticalDip"
or cbName == "SlideMonToOffset" or cbName == "SlideMonToOriginalPos" then
if noGfx or cbName == "HorizontalLunge" or cbName == "DoHorizontalLunge"
or cbName == "VerticalDip" or cbName == "DoVerticalDip"
or cbName == "SlideMonToOffset" or cbName == "SlideMonToOriginalPos"
or cbName == "BowMon" or cbName == "ShakeMonOrBattleTerrain" then
local taskName = cbName or "HorizontalLunge"
if taskName == "HorizontalLunge" then
if taskName == "HorizontalLunge" or taskName == "DoHorizontalLunge" then
AnimTasks.spawn("HorizontalLunge", 2, { args[1] or 4, args[2] or 4 }, vm)
elseif taskName == "SlideMonToOffset" or taskName == "SlideMonToOriginalPos" then
-- Approximate with brief shake/lunge until full slide port
AnimTasks.spawn("HorizontalLunge", 2, { 4, 4 }, vm)
AnimTasks.spawn("SlideMon", 2, args, vm)
elseif taskName == "BowMon" then
AnimTasks.spawn("BowMon", 2, args, vm)
elseif taskName == "ShakeMonOrBattleTerrain" then
AnimTasks.spawn("ShakeMonOrBattleTerrain", 2, args, vm)
else
AnimTasks.spawn(taskName, 2, args, vm)
end
@@ -371,18 +399,53 @@ local function run_createsprite(vm, op)
local tag = op.tag or (info and info.tag) or "IMPACT"
local img, tagInfo = tag_image(vm, tag)
if not img then
-- No extracted sheet yet — skip particle (do not invent circles)
return
img = vm:getImpactFallback()
end
-- Anchor: HitSplat args[3] is ANIM_TARGET/ATTACKER; Roar uses attacker
local isCutting = (cbName == "CuttingSlice" or cbName == "AirCutterSlice")
local isSlash = (cbName == "SlashSlice" or cbName == "FalseSwipeSlice" or cbName == "ClawSlash" or cbName == "FurySwipes")
local isBite = (cbName == "Bite" or cbName == "Fang" or cbName == "SuperFang")
local isProjectile = (cbName == "ThrowProjectile" or cbName == "BulletSeed" or cbName == "WaterBubbleProjectile" or cbName == "SludgeProjectile" or cbName == "BoneHitProjectile")
local isTargetAlways = (
isCutting or isBite or cbName == "AbsorptionOrb" or cbName == "BubbleEffect"
or cbName == "ConfuseRayBallSpiral" or cbName == "ConstrictBinding"
or cbName == "CrossChopHand" or cbName == "DizzyPunchDuck"
or cbName == "Electricity" or cbName == "EllipticalGust"
or cbName == "FlatterSpotlight" or cbName == "IceEffectParticle"
or cbName == "InitIceBallParticle" or cbName == "ItemSteal"
or cbName == "Lick" or cbName == "PresentHealParticle"
or cbName == "SlidingKick" or cbName == "SmallDriftingBubbles"
or cbName == "SpinningKickOrPunch" or cbName == "SporeParticle"
or cbName == "Spotlight" or cbName == "StompFoot"
or cbName == "TealAlert" or cbName == "WaterGunDroplet"
or cbName == "WaveFromCenterOfTarget"
)
local isDynamicArg3 = (
cbName == "SpriteOnMonPos" or cbName == "SpinningSparkle"
or cbName == "HitSplatBasic" or cbName == "HitSplatPersistent"
or cbName == "HitSplatRandom" or cbName == "CrossImpact"
or cbName == "FlashingHitSplat" or cbName == "BasicFistOrFoot"
or cbName == "RevengeScratch" or cbName == "ParticleInVortex"
or cbName == "SmallBubblePair" or cbName == "WhirlwindLine"
)
local isDynamicArg1 = (
isSlash or cbName == "EndureEnergy"
)
local anchorSide = vm:resolveBattlerSide(op.animBattler or "attacker")
if cbName == "HitSplatBasic" then
local hFlip = false
if isTargetAlways then
anchorSide = vm:resolveBattlerSide("target")
elseif isDynamicArg3 then
local which = args[3]
if which == "target" or which == 1 then
anchorSide = vm:resolveBattlerSide("target")
elseif which == "attacker" or which == 0 then
if which == 0 or which == "attacker" then
anchorSide = vm:resolveBattlerSide("attacker")
elseif which == 1 or which == 2 or which == "target" or (which and which ~= 0) then
anchorSide = vm:resolveBattlerSide("target")
else
for _, a in ipairs(args) do
if type(a) == "string" and a:lower():find("target") then
@@ -390,28 +453,68 @@ local function run_createsprite(vm, op)
end
end
end
elseif isDynamicArg1 then
if args[1] == 0 or args[1] == "attacker" then
anchorSide = vm:resolveBattlerSide("attacker")
else
anchorSide = vm:resolveBattlerSide("target")
end
elseif cbName == "RoarNoiseLine" then
anchorSide = vm:attackerSide()
end
local cx, cy = vm:battlerCenter(anchorSide)
local ox = vm:x(tonumber(args[1]) or 0)
local oy = tonumber(args[2]) or 0
if isCutting and anchorSide == "player" then
cy = cy + 8
end
local ox = 0
local oy = 0
local dir = 0
if isCutting then
dir = tonumber(args[3]) or 0
ox = (dir == 0 and 40 or -40)
oy = tonumber(args[2]) or -32
hFlip = (dir == 1)
elseif isSlash then
ox = vm:x(tonumber(args[2]) or 0)
oy = tonumber(args[3]) or 0
elseif cbName == "RoarNoiseLine" then
local argX = tonumber(args[1]) or 24
if vm.isReversed then argX = -argX end
ox = argX
oy = tonumber(args[2]) or 0
dir = tonumber(args[3]) or 0
else
ox = vm:x(tonumber(args[1]) or 0)
oy = tonumber(args[2]) or 0
end
local bw = op.w or (info and info.w) or 32
local bh = op.h or (info and info.h) or 32
-- noise_line sheet is 32x128; each frame is 32x32
if tag == "NOISE_LINE" then
if tag == "NOISE_LINE" or isCutting or isSlash or isBite then
bw, bh = 32, 32
end
local isBarrierShield = (cbName == "DefensiveWall" or cbName == "GuardRing" or cbName == "BlendThinRing" or cbName == "Protect")
local isBehindLayer = (cbName == "MudSportDirt" or cbName == "ShadowBall" or cbName == "Spikes")
local layer = isBehindLayer and "behind" or "front"
local zDepth = AnimSprites.slotZ(anchorSide, layer)
local blendMode = isBarrierShield and "add" or "alpha"
local spr = AnimSprites.acquire({
x = cx + ox,
y = cy + oy,
z = z_for_priority(op.subpriority or 2),
z = zDepth,
priority = op.priority or 2,
subpriority = op.subpriority or 0,
hostId = anchorSide,
blendMode = blendMode,
image = img,
w = bw,
h = bh,
hFlip = false,
hFlip = hFlip,
template = template,
tag = tag,
callback = AnimCallbacks.get(cbName),
@@ -421,12 +524,21 @@ local function run_createsprite(vm, op)
spr._baseW = bw
spr._baseH = bh
spr._reversed = vm.isReversed
spr._args = args
spr._anchorSide = anchorSide
spr._cbName = cbName
for k, v in ipairs(args) do
spr.data[k - 1] = v
end
spr.data[0] = 0
spr.data[1] = 0
spr.data[2] = tonumber(args[3]) or 0 -- roar direction
if cbName == "RoarNoiseLine" and vm.isReversed then
spr._reversed = true
end
spr.data[2] = isCutting and dir or (cbName == "RoarNoiseLine" and dir or (tonumber(args[3]) or 0))
local tx, ty = vm:battlerCenter(vm:resolveBattlerSide("target"))
local ax, ay = vm:battlerCenter(vm:resolveBattlerSide("attacker"))
spr._targetX, spr._targetY = tx, ty
spr._attackerX, spr._attackerY = ax, ay
spr._dx = tx - spr.x
spr._dy = ty - spr.y
end
end
+9 -6
View File
@@ -660,15 +660,18 @@ function Engine.planTurn(st, adapter)
return Engine.planTurnFromActions(st, adapter, nil, nil)
end
function Engine.collectResidualEvents(_st, adapter)
return Residuals.collectEvents(adapter)
end
function Engine.runResiduals(adapter)
local events = Residuals.collectEvents(adapter)
local captured = {}
local prev = adapter._say
-- Capture only; caller push_msgs after residual HP tweens.
adapter._say = function(text)
captured[#captured + 1] = text
for _, evt in ipairs(events or {}) do
for _, m in ipairs(evt.msgs or {}) do
captured[#captured + 1] = m
end
end
Residuals.runTurn(adapter)
adapter._say = prev
return captured
end
+63 -54
View File
@@ -1,4 +1,5 @@
-- Post-win evolution presentation (pret TryEvolvePokemon, EVO_LEVEL MVP).
-- Post-win evolution presentation (pret TryEvolvePokemon).
-- Handles headless fast-path and visual EvolutionScene chaining.
local Evolution = require("src.core.game3.evolution")
local LearnMove = require("src.core.game3.battle.learn_move")
@@ -13,23 +14,32 @@ EvoSeq._pushMsg = nil
EvoSeq._askYesNo = nil
EvoSeq._askForget = nil
EvoSeq._headless = false
EvoSeq._session = nil
function EvoSeq.reset()
EvoSeq._steps = nil
EvoSeq._i = 1
EvoSeq._waiting = false
EvoSeq._pushMsg = nil
EvoSeq._session = nil
LearnMove.reset()
end
function EvoSeq.busy()
local okEv, EvolutionScene = pcall(require, "src.ui.game3.evolution_scene")
if okEv and EvolutionScene and EvolutionScene.isOpen and EvolutionScene.isOpen() then
return true
end
return EvoSeq._steps ~= nil or LearnMove.busy()
end
local function finish()
local cb = EvoSeq._onDone
EvoSeq._steps = nil
EvoSeq._i = 1
EvoSeq._waiting = false
EvoSeq._onDone = nil
if cb then cb() end
end
local function advance()
@@ -45,32 +55,12 @@ function EvoSeq.begin(pending, opts)
EvoSeq._askYesNo = opts.askYesNo
EvoSeq._askForget = opts.askForget
EvoSeq._headless = opts.headless and true or false
EvoSeq._session = opts.session
EvoSeq._onDone = opts.onDone
local steps = {}
local function add(kind, data)
steps[#steps + 1] = { kind = kind, data = data }
end
for _, entry in ipairs(pending or {}) do
local mon = entry.mon
local fromName = Pokemon.displayMonName(mon)
-- If nicknamed, pret still says nickname is evolving / evolved into SPECIES
local intoName = Pokemon.name(entry.toSpecies) or ("POKéMON")
add("msg", { text = "What?\n" .. fromName .. " is evolving!" })
add("apply", {
mon = mon,
toSpecies = entry.toSpecies,
fromName = fromName,
intoName = intoName,
})
add("msg", {
text = "Congratulations! Your " .. fromName
.. "\nevolved into " .. intoName .. "!",
})
add("learn_evo", {
mon = mon,
displayName = fromName, -- nickname kept; species name changed after apply
})
steps[#steps + 1] = entry
end
if #steps == 0 then
@@ -82,32 +72,25 @@ function EvoSeq.begin(pending, opts)
return true
end
local function run_step(step)
if not step then
local function run_step(entry)
if not entry then
finish()
return
end
local kind = step.kind
local d = step.data or {}
if kind == "msg" then
if EvoSeq._pushMsg and d.text then
EvoSeq._pushMsg(d.text)
local mon = entry.mon
local toSpecies = entry.toSpecies or entry.target
local fromName = Pokemon.displayMonName(mon)
local intoName = Pokemon.name(toSpecies) or "POKéMON"
if EvoSeq._headless then
if EvoSeq._pushMsg then
EvoSeq._pushMsg("What?\n" .. fromName .. " is evolving!")
end
Evolution.apply(mon, toSpecies, EvoSeq._session)
if EvoSeq._pushMsg then
EvoSeq._pushMsg("Congratulations! Your " .. fromName .. "\nevolved into " .. intoName .. "!")
end
advance()
return
end
if kind == "apply" then
Evolution.apply(d.mon, d.toSpecies)
-- After evolve, display name for learn may still prefer nickname
advance()
return
end
if kind == "learn_evo" then
-- Learn moves the new species gets at the mon's current level (exact).
local mon = d.mon
local lv = tonumber(mon and mon.level) or 1
EvoSeq._waiting = true
local started = LearnMove.beginQueue(mon, { lv }, {
@@ -115,38 +98,64 @@ local function run_step(step)
pushMsg = EvoSeq._pushMsg,
askYesNo = EvoSeq._askYesNo,
askForget = EvoSeq._askForget,
headless = EvoSeq._headless,
headless = true,
onDone = function()
advance()
end,
})
if not started then
advance()
elseif not LearnMove.busy() and EvoSeq._waiting then
if EvoSeq._waiting then advance() end
end
return
end
advance()
-- Visual mode: launch dedicated EvolutionScene
local okEv, EvolutionScene = pcall(require, "src.ui.game3.evolution_scene")
if okEv and EvolutionScene and EvolutionScene.start then
local Audio = require("src.core.game3.audio")
local victorySong = (Audio._currentSong and Audio._currentSong.id) or Audio.role("victoryWild") or 311
EvoSeq._waiting = true
EvolutionScene.start(mon, toSpecies, {
canStop = true,
headless = EvoSeq._headless,
session = EvoSeq._session,
isBattle = true,
savedSong = victorySong,
onDone = function(result)
advance()
end,
})
else
-- Fallback
Evolution.apply(mon, toSpecies, EvoSeq._session)
advance()
end
end
function EvoSeq.update()
local okEv, EvolutionScene = pcall(require, "src.ui.game3.evolution_scene")
if okEv and EvolutionScene and EvolutionScene.isOpen and EvolutionScene.isOpen() then
return false
end
if LearnMove.busy() then
LearnMove.pump()
return false
end
if not EvoSeq._steps then return true end
if EvoSeq._waiting then
if LearnMove.busy() then return false end
advance()
return false
end
local step = EvoSeq._steps[EvoSeq._i]
if not step then
local entry = EvoSeq._steps[EvoSeq._i]
if not entry then
finish()
return true
end
run_step(step)
run_step(entry)
return false
end
+15 -4
View File
@@ -26,12 +26,12 @@ local HB_TEXT = {
shadow = { 216 / 255, 208 / 255, 176 / 255, 1 },
}
local HB_MALE = {
fg = { 64 / 255, 200 / 255, 248 / 255, 1 },
shadow = HB_TEXT.shadow,
fg = { 65 / 255, 205 / 255, 255 / 255, 1 },
shadow = { 0 / 255, 98 / 255, 148 / 255, 1 },
}
local HB_FEMALE = {
fg = { 248 / 255, 152 / 255, 144 / 255, 1 },
shadow = HB_TEXT.shadow,
fg = { 255 / 255, 156 / 255, 148 / 255, 1 },
shadow = { 156 / 255, 65 / 255, 57 / 255, 1 },
}
-- Inner cream right edge of assembled sheets (exclusive text end X).
@@ -222,9 +222,17 @@ function Healthbox.draw(side, battler)
local textRight = isPlayer and PLAYER_TEXT_RIGHT or ENEMY_TEXT_RIGHT
local gender = healthbox_gender(battler.mon)
local SummaryChrome = require("src.ui.game3.summary_chrome")
local SummaryData = require("src.core.game3.summary_data")
local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))
local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp })
if isPlayer then
draw_name_gender(name, gender, tlX + 16, ty)
draw_level(lv, tlX, ty, textRight)
if ailment >= 1 and ailment <= 6 then
SummaryChrome.drawStatusIcon(tlX + 16, tlY + 19, ailment)
end
local mon = battler.mon
if mon then
local cur, maxHp = display_hp_nums(side, battler)
@@ -245,6 +253,9 @@ function Healthbox.draw(side, battler)
else
draw_name_gender(name, gender, tlX + 8, ty)
draw_level(lv, tlX, ty, textRight)
if ailment >= 1 and ailment <= 6 then
SummaryChrome.drawStatusIcon(tlX - 2, tlY + 17, ailment)
end
end
end
+91 -21
View File
@@ -35,6 +35,9 @@ Battle._auto = false
Battle._metaAct = nil
Battle._headless = false
Battle._lowHpSong = false
Battle._residualEvents = nil
Battle._residualIndex = 1
Battle._residualStepState = nil
-- pret GetHPBarLevel: red when scaled bar pixels are in (0, 20%] of 48.
local HP_BAR_PIXELS = 48
@@ -165,6 +168,9 @@ local function finish(result)
stop_low_hp_song()
Battle._active = false
Battle._phase = nil
Battle._residualEvents = nil
Battle._residualIndex = 1
Battle._residualStepState = nil
local st = Battle._st
if st then
st.over = true
@@ -389,14 +395,16 @@ local function begin_evo_or_end()
return
end
local leveled = Battle._leveledUp or ExpSeq.leveledSet()
local pending = Evolution.pending(st.playerParty, leveled)
local Runtime = package.loaded["src.core.game3.runtime"]
local session = (Runtime and Runtime.getSession and Runtime.getSession()) or (Battle._st and Battle._st.session)
local pending = Evolution.pending(st.playerParty, leveled, session)
if Battle._headless then
local Pokemon = require("src.core.game3.pokemon")
for _, entry in ipairs(pending) do
local fromName = Pokemon.displayMonName(entry.mon)
local intoName = Pokemon.name(entry.toSpecies) or "?"
Ui.push("What?\n" .. fromName .. " is evolving!")
Evolution.apply(entry.mon, entry.toSpecies)
Evolution.apply(entry.mon, entry.toSpecies, session)
Ui.push("Congratulations! Your " .. fromName
.. "\nevolved into " .. intoName .. "!")
end
@@ -404,6 +412,7 @@ local function begin_evo_or_end()
return
end
local hooks = choice_hooks()
hooks.session = session
local started = EvoSeq.begin(pending, hooks)
if started then
Battle._phase = "evolving"
@@ -606,8 +615,8 @@ local function handle_enemy_faint(opts)
begin_evo_or_end()
end
else
send_out_enemy_next(nextEnemyIdx)
if opts.onFinished then
send_out_enemy_next(nextEnemyIdx)
opts.onFinished()
return
end
@@ -720,27 +729,27 @@ end
local function after_actions()
local st = Battle._st
local ad = Battle._adapter
local msgs = Engine.runResiduals(ad)
for _, side in ipairs({ "player", "enemy" }) do
local b = st[side]
local p = Anim.present(side)
if b and b.mon and p and p.displayHp ~= nil then
local logical = tonumber(b.mon.hp) or 0
if math.abs(logical - (p.displayHp or logical)) >= 1 then
Anim.tweenHp(side, p.displayHp, logical, b.mon.maxHp)
else
p.displayHp = logical
end
local events = Engine.collectResidualEvents(st, ad)
Battle._residualEvents = events
Battle._residualIndex = 1
Battle._residualStepState = "start"
if Battle._headless then
for _, evt in ipairs(events or {}) do
push_msgs(evt.msgs)
end
Ui.pump()
if check_faints_and_end() then
return
end
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
end
end
push_msgs(msgs)
if check_faints_and_end() then
return
end
Battle._phase = "command"
if not Battle._auto then
Ui.openMenu()
end
Battle._phase = "residuals"
end
local function step_action()
@@ -997,6 +1006,7 @@ function Battle.update(dt, game)
and not Battle._auto and game and game.input then
if Ui.choiceActive and Ui.choiceActive() then
Ui.handleInput(game.input)
return
end
end
@@ -1168,6 +1178,66 @@ function Battle.update(dt, game)
return
end
-- End-of-turn residual presentation (poison, burn, weather, leech seed, etc.)
if Battle._phase == "residuals" then
local events = Battle._residualEvents or {}
if Battle._residualIndex > #events then
Battle._residualEvents = nil
Battle._residualIndex = 1
Battle._residualStepState = nil
if check_faints_and_end() then
return
end
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
else
Ui.openMenu()
end
return
end
local evt = events[Battle._residualIndex]
if Battle._residualStepState == "start" then
for _, hp in ipairs(evt.hpChanges or {}) do
Anim.tweenHp(hp.side, hp.from, hp.to, hp.maxHp)
end
for _, msg in ipairs(evt.msgs or {}) do
Ui.push(msg)
end
-- Trigger Ui.pump() simultaneously with HP tween so text appears immediately
Ui.pump()
Battle._residualStepState = "running"
return
end
if Battle._residualStepState == "running" then
if Anim.busy() then return end
if not Ui.pump() then return end
-- Check if any faints occurred in this event
if evt.faints and #evt.faints > 0 then
for _, f in ipairs(evt.faints) do
local p = Anim.present(f.side)
if p and p.visible ~= false and not Battle._headless then
Anim.faintMon(f.side)
end
end
if check_faints_and_end() then
Battle._residualEvents = nil
Battle._residualIndex = 1
Battle._residualStepState = nil
return
end
end
-- Advance to next residual event
Battle._residualIndex = Battle._residualIndex + 1
Battle._residualStepState = "start"
return
end
end
if Anim.busy() then return end
if not Ui.pump() then return end
+73 -40
View File
@@ -58,9 +58,11 @@ local function finish(learned)
if cb then cb(learned == true) end
end
local function say(text)
local function say(text, cb)
if LearnMove._pushMsg and text then
LearnMove._pushMsg(text)
LearnMove._pushMsg(text, cb)
elseif cb then
cb()
end
end
@@ -68,51 +70,53 @@ local function move_name(moveId)
return Pokemon.moveName(moveId) or ("MOVE " .. tostring(moveId))
end
local function schedule(kind)
LearnMove._pending = kind
end
local open_delete_prompt
local open_stop_prompt
local open_forget_list
local function open_delete_prompt()
function open_delete_prompt()
if LearnMove._headless or not LearnMove._askYesNo then
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".")
finish(false)
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".", function()
finish(false)
end)
return
end
LearnMove._waitingChoice = true
LearnMove._askYesNo(function(yes)
LearnMove._askYesNo("Should a move be deleted and\nreplaced with " .. LearnMove._moveName .. "?", function(yes)
LearnMove._waitingChoice = false
if not yes then
schedule("stop")
open_stop_prompt()
return
end
schedule("forget")
open_forget_list()
end)
end
local function open_stop_prompt()
function open_stop_prompt()
if LearnMove._headless or not LearnMove._askYesNo then
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".")
finish(false)
say(LearnMove._name .. " did not learn the\nmove " .. LearnMove._moveName .. ".", function()
finish(false)
end)
return
end
say("Stop learning\n" .. LearnMove._moveName .. "?")
LearnMove._waitingChoice = true
LearnMove._askYesNo(function(stop)
LearnMove._askYesNo("Stop trying to teach\n" .. LearnMove._moveName .. "?", function(stop)
LearnMove._waitingChoice = false
if stop then
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".")
finish(false)
say(LearnMove._name .. " did not learn the\nmove " .. LearnMove._moveName .. ".", function()
finish(false)
end)
else
say("Delete an older move to\nmake room for " .. LearnMove._moveName .. "?")
schedule("delete")
open_delete_prompt()
end
end)
end
local function open_forget_list()
function open_forget_list()
if LearnMove._headless or not LearnMove._askForget then
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".")
finish(false)
say(LearnMove._name .. " did not learn the\nmove " .. LearnMove._moveName .. ".", function()
finish(false)
end)
return
end
local opts, slots = {}, {}
@@ -130,26 +134,39 @@ local function open_forget_list()
LearnMove._askForget(opts, function(idx)
LearnMove._waitingChoice = false
if idx == nil or idx < 0 or idx >= #slots then
schedule("stop")
open_stop_prompt()
return
end
local slot = slots[idx + 1]
local oldId = Pokemon.moveIdAt(LearnMove._mon, slot)
if Pokemon.isHmMove(oldId) then
say("HM moves can't be\nforgotten now.")
say("Delete an older move to\nmake room for " .. LearnMove._moveName .. "?")
schedule("delete")
say("HM moves can't be\nforgotten now.", function()
open_delete_prompt()
end)
return
end
local forgotten = Pokemon.replaceMove(LearnMove._mon, slot, LearnMove._moveId)
if forgotten then
say("1, 2, and… Poof!")
say(LearnMove._name .. " forgot\n" .. move_name(forgotten) .. "!")
say("And…")
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!")
finish(true)
pcall(function() require("src.core.game3.audio").playFanfare(257) end)
if LearnMove._headless then
say("1, 2, and… Poof!")
say(LearnMove._name .. " forgot how to\nuse " .. move_name(forgotten) .. ".")
say("And...")
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!")
finish(true)
else
say("1, 2, and… Poof!", function()
say(LearnMove._name .. " forgot how to\nuse " .. move_name(forgotten) .. ".", function()
say("And...", function()
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!", function()
finish(true)
end)
end)
end)
end)
end
else
schedule("delete")
open_delete_prompt()
end
end)
end
@@ -203,20 +220,36 @@ function LearnMove.begin(opts)
if Pokemon.moveSlotCount(mon) < 4 then
local ok = Pokemon.teachMove(mon, moveId)
if ok then
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!")
pcall(function() require("src.core.game3.audio").playFanfare(257) end)
if LearnMove._headless then
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!")
finish(ok)
else
say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!", function()
finish(ok)
end)
end
else
finish(false)
end
finish(ok)
return
end
say(LearnMove._name .. " is trying to\nlearn " .. LearnMove._moveName .. ".")
say("But, " .. LearnMove._name .. " can't learn\nmore than four moves.")
say("Delete an older move to\nmake room for " .. LearnMove._moveName .. "?")
schedule("delete")
if LearnMove._headless then
say(LearnMove._name .. " did not learn\n" .. LearnMove._moveName .. ".")
finish(false)
return
end
say(LearnMove._name .. " wants to learn the\nmove " .. LearnMove._moveName .. ".", function()
say("However, " .. LearnMove._name .. " already\nknows four moves.", function()
open_delete_prompt()
end)
end)
end
function LearnMove.movesForLevels(mon, levels)
local species = tonumber(mon and (mon.species or mon.speciesId))
local species = Pokemon.speciesOf(mon) or tonumber(mon and (mon.species or mon.speciesId))
local out = {}
if not species then return out end
local seen = {}
+110 -22
View File
@@ -16,40 +16,128 @@ function Residuals.clear()
handlers = {}
end
local function runPhaseList(adapter, battler, phase, list)
if not list then return end
local function battlerSpeed(battler)
if not battler then return 0 end
local mon = battler.mon
local spe = tonumber(mon and (mon.speed or mon.spe)) or 50
if battler.stages and battler.stages.speed then
local Damage = require("src.core.game3.battle.damage")
spe = spe * Damage.stageMul(battler.stages.speed)
end
return spe
end
local function sortedBattlers(adapter)
local list = adapter:activeBattlers() or {}
table.sort(list, function(a, b)
local speA = battlerSpeed(a)
local speB = battlerSpeed(b)
if speA ~= speB then
return speA > speB
end
return (a.side == "player")
end)
return list
end
local function runStepAndRecord(adapter, battler, phase, fn, events)
if battler and adapter:isFainted(battler) then return false end
if adapter:isBattleDecided() then return false end
local active = adapter:activeBattlers() or {}
local hpBefore = {}
for _, b in ipairs(active) do
if b and b.side then
hpBefore[b.side] = adapter:hp(b)
end
end
local capturedMsgs = {}
local prevSay = adapter._say
adapter._say = function(text)
capturedMsgs[#capturedMsgs + 1] = tostring(text or "")
end
local opts = EffectCtx.borrowOpts(phase)
for _, fn in ipairs(list) do
if battler and adapter:isFainted(battler) then break end
if adapter:isBattleDecided() then return end
local ctx = EffectCtx.push(adapter, nil, battler, nil, nil, adapter:rng(), opts)
fn(ctx)
EffectCtx.pop()
if battler and adapter:isFainted(battler) then
adapter:emitFaint(battler)
if Rules.shouldHaltBattlerOnFaint(phase) then
break
local ctx = EffectCtx.push(adapter, nil, battler, nil, nil, adapter:rng(), opts)
fn(ctx)
EffectCtx.pop()
adapter._say = prevSay
local hpChanges = {}
local faints = {}
for _, b in ipairs(active) do
if b and b.side then
local before = hpBefore[b.side] or 0
local after = adapter:hp(b)
if before ~= after then
hpChanges[#hpChanges + 1] = {
side = b.side,
from = before,
to = after,
maxHp = adapter:maxHp(b),
}
end
if adapter:isFainted(b) and before > 0 then
faints[#faints + 1] = { side = b.side }
adapter:emitFaint(b)
end
end
end
if #capturedMsgs > 0 or #hpChanges > 0 or #faints > 0 then
events[#events + 1] = {
phase = phase,
target = battler,
msgs = capturedMsgs,
hpChanges = hpChanges,
faints = faints,
}
end
if battler and adapter:isFainted(battler) then
if Rules.shouldHaltBattlerOnFaint(phase) then
return true
end
end
return false
end
function Residuals.runTurn(adapter)
if not adapter then return end
if adapter:isBattleDecided() then return end
function Residuals.collectEvents(adapter)
local events = {}
if not adapter or adapter:isBattleDecided() then return events end
for _, phase in ipairs(Rules.phaseOrder()) do
if adapter:isBattleDecided() then return end
if adapter:isBattleDecided() then break end
local list = handlers[phase]
if Rules.isFieldPhase(phase) then
runPhaseList(adapter, nil, phase, list)
else
for _, battler in ipairs(adapter:activeBattlers()) do
if not adapter:isFainted(battler) then
runPhaseList(adapter, battler, phase, list)
if list and #list > 0 then
if Rules.isFieldPhase(phase) then
for _, fn in ipairs(list) do
if adapter:isBattleDecided() then break end
runStepAndRecord(adapter, nil, phase, fn, events)
end
else
local battlers = sortedBattlers(adapter)
for _, battler in ipairs(battlers) do
if not adapter:isFainted(battler) then
for _, fn in ipairs(list) do
if adapter:isBattleDecided() then break end
local halt = runStepAndRecord(adapter, battler, phase, fn, events)
if halt then break end
end
end
end
end
end
end
return events
end
function Residuals.runTurn(adapter)
local events = Residuals.collectEvents(adapter)
return events
end
return Residuals
+32 -14
View File
@@ -45,16 +45,16 @@ end)
--- pret GetBattlerSpriteFinal_Y (a3=TRUE / BATTLER_COORD_Y_PIC_OFFSET).
local function battler_sprite_center(side, species, base)
local cx, cy = base.x, base.y
local cx, cy = base.x, (side == "player") and (base.y - 4) or base.y
if not PicCoords or not species then return cx, cy end
local sp = tonumber(species) or 0
if side == "player" then
local yo = (PicCoords.back and PicCoords.back[sp]) or 0
cy = cy + yo + 8 -- player +8 when a3
cy = base.y + yo + 4 -- shifted up 4px
else
local yo = (PicCoords.front and PicCoords.front[sp]) or 0
local elev = (PicCoords.elev and PicCoords.elev[sp]) or 0
cy = cy + yo - elev
cy = base.y + yo - elev
end
return cx, cy
end
@@ -358,10 +358,11 @@ function Ui.handleInput(input)
return false
end
local function draw_menu_text(text, x, y)
local function draw_menu_text(text, x, y, opts)
opts = opts or {}
FrlgFont.draw(tostring(text or ""), x, y, {
small = true,
colors = FrlgFont.COLOR.PARTY,
small = opts.small ~= nil and opts.small or false,
colors = opts.colors or FrlgFont.COLOR.NORMAL,
})
end
@@ -410,8 +411,9 @@ local function draw_mon_sprite(battler, base, back)
love.graphics.setColor(shade, shade, shade, a)
end
local hFlip = (pres and pres.hFlip) and true or false
local sx = (hFlip and -1 or 1) * scale
love.graphics.draw(entry.image, cx, cy, 0, sx, scale, 32, 32)
local sx = (hFlip and -1 or 1) * ((pres and pres.sx) or scale)
local sy = (pres and pres.sy) or scale
love.graphics.draw(entry.image, cx, cy, 0, sx, sy, 32, 32)
else
-- Placeholder silhouette so lunge/shake is visible before full pic extract.
local a = (pres and pres.alpha) or 1
@@ -450,7 +452,7 @@ local function draw_action_menu(st)
local cp = cursorPos[c + 1] or cursorPos[1]
Window.cursorPx(cp[1], cp[2], { colors = FrlgFont.COLOR.NORMAL })
for i, pos in ipairs(positions) do
draw_menu_text(labels[i], pos[1], pos[2])
draw_menu_text(labels[i], pos[1], pos[2], { small = false, colors = FrlgFont.COLOR.NORMAL })
end
end
@@ -473,7 +475,7 @@ local function draw_move_menu(st)
if mv and mv ~= 0 and mv ~= "" then
label = Moves.displayName(mv)
end
draw_menu_text(label, positions[i][1], positions[i][2])
draw_menu_text(label, positions[i][1], positions[i][2], { small = true, colors = FrlgFont.COLOR.NORMAL })
end
local slot = Ui._moveIndex
local mv = mon and mon.moves and mon.moves[slot]
@@ -481,8 +483,8 @@ local function draw_move_menu(st)
local def = Moves.get(mv)
local pp = mon.pp and mon.pp[slot] or 0
local maxPp = mon.maxPp and mon.maxPp[slot] or (def and def.pp) or pp
draw_menu_text(string.format("PP %d/%d", pp, maxPp), 168, 122)
draw_menu_text(Types.get(def and def.type) or "NORMAL", 168, 138)
draw_menu_text(string.format("PP %d/%d", pp, maxPp), 168, 122, { small = true, colors = FrlgFont.COLOR.NORMAL })
draw_menu_text(Types.get(def and def.type) or "NORMAL", 168, 138, { small = true, colors = FrlgFont.COLOR.NORMAL })
end
end
@@ -669,20 +671,36 @@ function Ui.draw(w, h)
playerOx = stage.bgSlide.playerOx or 0
end
local bgDim = (stage and stage.bgDim) or 0
if bgDim > 0 then
local s = math.max(0, 1 - bgDim * 0.65)
love.graphics.setColor(s, s, s, 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
if not BattleBg.draw(nil, enemyOx, playerOx) then
love.graphics.setColor(0.92, 0.94, 0.96, 1)
love.graphics.rectangle("fill", 0, 0, w, 112)
end
love.graphics.setColor(1, 1, 1, 1)
-- pret-ish z: trainers → mons → particles → ball → healthboxes → party bars
-- pret-ish 5-layer z:
-- 1. Behind Enemy & Background FX (Z: 0 .. 99)
-- 2. Enemy Mon (Z: 100)
-- 3. In front of Enemy / Behind Player / Mid-field (Z: 101 .. 199)
-- 4. Player Mon (Z: 200)
-- 5. In front of Player & Global Foreground (Z: 201 .. 999)
draw_trainer_sprites(stage)
Anim.drawParticles(0, 99)
if st then
draw_mon_sprite(st.enemy, ENEMY_MON, false)
end
Anim.drawParticles()
Anim.drawParticles(101, 199)
if st then
draw_mon_sprite(st.player, PLAYER_MON, true)
end
Anim.drawParticles(201, 999)
draw_intro_ball(stage)
if st then
Healthbox.draw("enemy", st.enemy)
+99 -16
View File
@@ -35,7 +35,7 @@ end
function Evolution.levelTarget(mon, session)
if not mon then return nil end
if held_is_everstone(mon) then return nil end
local species = tonumber(mon.species or mon.speciesId)
local species = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId)
local level = tonumber(mon.level) or 1
if not species then return nil end
for _, evo in ipairs(Pokemon.evolutions(species)) do
@@ -61,11 +61,10 @@ function Evolution.levelTarget(mon, session)
return nil
end
--- Target species for item/stone evolution, or nil.
--- Target species for item/stone evolution, or nil (stones bypass Everstone).
function Evolution.itemTarget(mon, itemId, session)
if not mon then return nil end
if held_is_everstone(mon) then return nil end
local species = tonumber(mon.species or mon.speciesId)
local species = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId)
local ItemsData = require("src.core.game3.items_data")
local num = ItemsData.toNumericId(itemId) or tonumber(itemId)
if not species or not num then return nil end
@@ -84,36 +83,120 @@ function Evolution.itemTarget(mon, itemId, session)
return nil
end
--- Apply species change + stats. Preserves HP ratio-ish via maxHp delta.
function Evolution.apply(mon, newSpecies)
--- Rename mon on evolution matching retail FRLG EvolutionRenameMon rules.
function Evolution.renameMon(mon, preSpecies, postSpecies)
if not mon then return end
local preName = Pokemon.name(preSpecies) or ""
local newName = Pokemon.name(postSpecies) or "POKéMON"
local function trim(s)
if type(s) ~= "string" then return "" end
return (s:gsub("%z+", ""):match("^%s*(.-)%s*$")) or ""
end
local nick = trim(mon.nickname)
local pName = trim(preName)
if nick == "" or nick:upper() == pName:upper() then
mon.nickname = newName
mon.name = newName
else
mon.name = nick
end
end
--- Apply species change + stats. Point of no return.
function Evolution.apply(mon, newSpecies, session, bag)
newSpecies = tonumber(newSpecies)
if not mon or not newSpecies then return false end
local preSpecies = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId) or 1
local oldMax = tonumber(mon.maxHp) or 1
local oldHp = tonumber(mon.hp) or oldMax
-- 1. Mutate species
mon.species = newSpecies
mon.speciesId = newSpecies
local nm = Pokemon.name(newSpecies)
if nm then
-- Only replace species name if no nickname
if not mon.nickname or mon.nickname == "" then
mon.name = nm
end
end
-- 2. Nickname update
Evolution.renameMon(mon, preSpecies, newSpecies)
-- 3. Recalculate stats & handle HP delta
Pokemon.applyStats(mon)
local newMax = tonumber(mon.maxHp) or oldMax
mon.hp = math.min(newMax, oldHp + math.max(0, newMax - oldMax))
if oldHp > 0 then
mon.hp = math.min(newMax, oldHp + math.max(0, newMax - oldMax))
else
mon.hp = 0 -- preserve fainted status
end
-- 4. Pokedex registration
if session and session.dex then
local Dex = require("src.core.game3.dex")
Dex.setSeen(session.dex, newSpecies)
Dex.setCaught(session.dex, newSpecies)
end
-- 5. Shedinja Creation (Nincada -> Ninjask)
local isNincada = (preSpecies == 290 or preSpecies == 301)
local isNinjask = (newSpecies == 291 or newSpecies == 302)
local shedId = (newSpecies == 302) and 303 or 292
if isNincada and isNinjask and session then
local party = session.party or (session.save and session.save.party)
if party and #party < 6 then
local hasPokeBall = false
local BagMod = package.loaded["src.core.game3.bag"] or require("src.core.game3.bag")
local b = bag or session.bag
if b then
if BagMod.has and BagMod.has(b, 4, 1) then
hasPokeBall = true
BagMod.remove(b, 4, 1)
elseif type(b.has) == "function" and b:has(4, 1) then
hasPokeBall = true
if type(b.remove) == "function" then b:remove(4, 1) end
end
end
if hasPokeBall then
-- Deep clone Nincada before Ninjask learns new moves
local shedinja = {}
for k, v in pairs(mon) do
if type(v) == "table" then
local t = {}
for k2, v2 in pairs(v) do t[k2] = v2 end
shedinja[k] = t
else
shedinja[k] = v
end
end
shedinja.species = shedId
shedinja.speciesId = shedId
shedinja.name = Pokemon.name(shedId) or "SHEDINJA"
shedinja.nickname = Pokemon.name(shedId) or "SHEDINJA"
shedinja.heldItem = 0
shedinja.item = 0
shedinja.status = 0
shedinja.pokeball = 4
shedinja.ability = 25 -- ABILITY_WONDER_GUARD
shedinja.maxHp = 1
shedinja.hp = 1
party[#party + 1] = shedinja
if session.dex then
local Dex = require("src.core.game3.dex")
Dex.setSeen(session.dex, shedId)
Dex.setCaught(session.dex, shedId)
end
end
end
end
return true
end
--- Scan party (or indices) for pending level evolutions.
-- leveledSet: optional {[partyIndex]=true} from battle.
-- Returns { {mon, partyIndex, fromSpecies, toSpecies}, ... }
function Evolution.pending(party, leveledSet)
function Evolution.pending(party, leveledSet, session)
local out = {}
if type(party) ~= "table" then return out end
for i, mon in ipairs(party) do
if mon and (not leveledSet or leveledSet[i]) then
local target = Evolution.levelTarget(mon)
local target = Evolution.levelTarget(mon, session)
if target then
out[#out + 1] = {
mon = mon,
+5 -2
View File
@@ -114,11 +114,14 @@ function Gfx.drawUi()
end
-- Dialog then choice on top (yesnobox overlays stayed message).
if Message.isOpen() and not Stack.has("box_storage") and not Stack.has("pc_menu") then
local top = Stack.top()
local suppressOverworldDialog = top and top.hideBelow
if Message.isOpen() and not suppressOverworldDialog and not Stack.has("box_storage") and not Stack.has("pc_menu") then
Message.draw()
end
if Choice.active and Choice.options then
if Choice.active and Choice.options and not suppressOverworldDialog then
tryDraw(Choice)
end
+67 -100
View File
@@ -6,13 +6,6 @@ local Pokemon = require("src.core.game3.pokemon")
local ItemUse = {}
local function msg(text)
local Hud = require("src.ui.game3.hud")
if Hud and Hud.openMessage then
Hud.openMessage(nil, text)
end
end
local function heal_amount(id)
local n = ItemsData.HEAL_AMOUNT[id]
if n then return n end
@@ -138,9 +131,7 @@ function ItemUse.giveToMon(session, bag, id, partySlot)
if not mon then return false, "noparty", "There's no POKéMON!" end
local pocket = ItemsData.pocketOf(id)
if pocket == "KEY_ITEMS" or pocket == "TM_CASE" then
local t = "This item can't be held."
msg(t)
return false, "cant_hold", t
return false, "cant_hold", "This item can't be held."
end
if not Bag.has(bag, id, 1) then
return false, "none", "You don't have that item."
@@ -149,9 +140,7 @@ function ItemUse.giveToMon(session, bag, id, partySlot)
if prev and prev ~= 0 and prev ~= "" and prev ~= "NONE" then
-- Swap: return previous to bag if possible
if not Bag.canAdd(bag, prev, 1) then
local t = "The BAG is full."
msg(t)
return false, "bag_full", t
return false, "bag_full", "The BAG is full."
end
end
Bag.remove(bag, id, 1)
@@ -167,7 +156,6 @@ function ItemUse.giveToMon(session, bag, id, partySlot)
else
text = string.format("%s was given\nto %s.", ItemsData.displayName(id), monName)
end
msg(text)
return true, "give", text
end
@@ -179,20 +167,15 @@ function ItemUse.takeFromMon(session, bag, partySlot)
local held = mon.item or mon.heldItem
local monName = Pokemon.displayMonName(mon)
if not held or held == 0 or held == "" or held == "NONE" then
local t = monName .. " isn't\nholding anything."
msg(t)
return false, "none", t
return false, "none", monName .. " isn't\nholding anything."
end
if not Bag.canAdd(bag, held, 1) then
local t = "The BAG is full. The\nitem could not be removed."
msg(t)
return false, "bag_full", t
return false, "bag_full", "The BAG is full. The\nitem could not be removed."
end
mon.item = nil
mon.heldItem = nil
Bag.add(bag, held, 1)
local text = string.format("Took the %s from\n%s and put it in the BAG.", ItemsData.displayName(held), monName)
msg(text)
return true, "take", text
end
@@ -231,9 +214,7 @@ end
function ItemUse.useEscapeRope(session, bag, id)
if not can_escape(session) then
local t = "OAK: This isn't the\ntime to use that!"
msg(t)
return false, "escape", t
return false, "escape", "OAK: This isn't the\ntime to use that!"
end
local Runtime = package.loaded["src.core.game3.runtime"]
local mod = Runtime and Runtime._mod
@@ -241,7 +222,6 @@ function ItemUse.useEscapeRope(session, bag, id)
local Field = package.loaded["src.core.game3.field"]
Bag.remove(bag, id, 1)
local t = tostring(session.name or "RED") .. " used\nESCAPE ROPE."
msg(t)
local hx = session.healX or 8
local hy = session.healY or 5
local mapId = session.healMap
@@ -258,9 +238,7 @@ end
function ItemUse.useBike(session)
if not is_outdoor(session) then
local t = "OAK: This isn't the\ntime to use that!"
msg(t)
return false, "bike", t
return false, "bike", "OAK: This isn't the\ntime to use that!"
end
local Player = require("src.core.game3.player")
Player.biking = not Player.biking
@@ -270,40 +248,43 @@ function ItemUse.useBike(session)
else
t = tostring(session.name or "RED") .. " got off the\nBICYCLE."
end
msg(t)
return true, "bike", t
end
--- Check TM pre-flight compatibility and known moves matching retail FRLG.
-- Returns status ("knows" | "incompatible" | "ok"), messageText, moveId, moveName
function ItemUse.checkTmPreflight(mon, tmId)
if not mon then return "none", "There's no POKéMON!", nil, nil end
local moveId = Pokemon.moveFromTmItem(tmId)
local monName = Pokemon.displayMonName(mon)
local moveName = Pokemon.moveName(moveId) or "MOVE"
if not moveId then
return "invalid", "This isn't the time to use\nthat!", nil, nil
end
if Pokemon.knowsMove(mon, moveId) then
return "knows", string.format("%s already knows\n%s.", monName, moveName), moveId, moveName
end
local species = tonumber(mon.species or mon.speciesId)
if not Pokemon.canLearnTmItem(species, tmId) then
return "incompatible", string.format("%s can't learn\n%s.", monName, moveName), moveId, moveName
end
local prompt = string.format("Booted up a TM.\nIt contained %s.\nTeach %s to %s?", moveName, moveName, monName)
return "ok", prompt, moveId, moveName
end
--- Teach TM/HM. partySlot required. Consumes TM (not HM).
function ItemUse.useTm(session, bag, id, partySlot)
local party = session and session.party
local mon = party and party[partySlot]
if not mon then
local t = "There's no POKéMON!"
msg(t)
return false, "noparty", t
return false, "noparty", "There's no POKéMON!"
end
local status, preflightMsg, moveId, moveName = ItemUse.checkTmPreflight(mon, id)
if status ~= "ok" then
return false, status, preflightMsg
end
local moveId = Pokemon.moveFromTmItem(id)
local tmName = ItemsData.displayName(id) or "TM"
local monName = Pokemon.displayMonName(mon)
if not moveId then
local t = "This isn't the time to use\nthat!"
msg(t)
return false, "tm", t
end
local species = tonumber(mon.species or mon.speciesId)
if not Pokemon.canLearnTmItem(species, id) then
local t = string.format("%s and %s\nare not compatible.\nIt can't be learned.", monName, tmName)
msg(t)
return false, "cant_learn", t
end
if Pokemon.knowsMove(mon, moveId) then
local t = string.format("%s already knows\n%s.", monName, Pokemon.moveName(moveId) or "this move")
msg(t)
return false, "knows", t
end
local LearnMove = require("src.core.game3.battle.learn_move")
local isHm = ItemsData.isHm(id)
local consumed = false
@@ -318,29 +299,24 @@ function ItemUse.useTm(session, bag, id, partySlot)
local ok = Pokemon.teachMove(mon, moveId)
if ok then
finish_consume(true)
local t = string.format("%s learned\n%s!", monName, Pokemon.moveName(moveId))
msg(t)
return true, "tm", t
return true, "tm", string.format("%s learned\n%s!", monName, moveName)
end
end
local LearnMove = require("src.core.game3.battle.learn_move")
LearnMove.begin({
mon = mon,
moveId = moveId,
displayName = monName,
headless = true,
pushMsg = msg,
onDone = function(learned)
finish_consume(learned)
end,
})
if Pokemon.moveSlotCount(mon) >= 4 then
local t = monName .. " can't learn\nmore than four moves."
msg(t)
return false, "full", t
return false, "full", monName .. " can't learn\nmore than four moves."
end
local t = string.format("%s learned\n%s!", monName, Pokemon.moveName(moveId))
return true, "tm", t
return true, "tm", string.format("%s learned\n%s!", monName, moveName)
end
--- Check if using this item requires selecting a party Pokémon target.
@@ -360,36 +336,44 @@ end
function ItemUse.useRareCandy(session, mon)
if not mon then return false, "none", "There's no POKéMON!" end
local lvl = tonumber(mon.level) or 1
if lvl >= 100 then
local t = "It won't have any effect."
msg(t)
return false, "max_level", t
local hp = tonumber(mon.hp) or 0
if lvl >= 100 or hp <= 0 then
return false, "no_effect", "It won't have any effect."
end
mon.level = lvl + 1
local oldMax = tonumber(mon.maxHp) or tonumber(mon.maxhp) or 1
local oldHp = tonumber(mon.hp) or oldMax
local oldHp = hp
mon.level = lvl + 1
Pokemon.applyStats(mon)
local newMax = tonumber(mon.maxHp) or tonumber(mon.maxhp) or oldMax
mon.hp = math.min(newMax, oldHp + math.max(0, newMax - oldMax))
local t = string.format("%s grew to\nLv. %d!", Pokemon.displayMonName(mon), mon.level)
msg(t)
return true, "level", t
end
function ItemUse.useEvolutionStone(session, mon, itemId)
function ItemUse.useEvolutionStone(session, mon, itemId, bag)
local Evolution = require("src.core.game3.evolution")
local target = Evolution.itemTarget and Evolution.itemTarget(mon, itemId, session)
if not target then
local t = "It won't have any effect."
msg(t)
return false, "no_evo", t
return false, "no_evo", "It won't have any effect."
end
local oldName = Pokemon.displayMonName(mon)
Evolution.apply(mon, target)
local newName = Pokemon.name(target) or "POKéMON"
local t = string.format("%s evolved into\n%s!", oldName, newName)
msg(t)
return true, "evo", t
local okEv, EvolutionScene = pcall(require, "src.ui.game3.evolution_scene")
if okEv and EvolutionScene and EvolutionScene.start and love and love.graphics then
local Audio = require("src.core.game3.audio")
EvolutionScene.start(mon, target, {
canStop = false,
session = session,
bag = bag,
savedSong = Audio._mapSong,
})
return true, "evo", "Evolving..."
else
Evolution.apply(mon, target, session, bag)
local t = string.format("%s evolved into\n%s!", oldName, newName)
return true, "evo", t
end
end
--- Try field use. partySlot optional for heal/status/revive/tm/give.
@@ -400,9 +384,7 @@ function ItemUse.useField(session, bag, id, partySlot)
local use = ItemsData.fieldUseKind(id)
if use == "battle" then
local t = "This can't be used outside\nof battle."
msg(t)
return false, "battle", t
return false, "battle", "This can't be used outside\nof battle."
end
if use == "map" then
@@ -426,7 +408,6 @@ function ItemUse.useField(session, bag, id, partySlot)
session.repelSteps = steps
Bag.remove(bag, id, 1)
local t = "The repelling effect wore\non for a while."
msg(t)
return true, "repel", t
end
@@ -437,7 +418,6 @@ function ItemUse.useField(session, bag, id, partySlot)
return false, "open_pocket", "Opened pocket."
end
local t = "OAK: This isn't the\ntime to use that!"
msg(t)
return false, use, t
end
@@ -445,18 +425,14 @@ function ItemUse.useField(session, bag, id, partySlot)
or use == "pp" or use == "level" or use == "evo" or use == "vitamin" then
local party = session and session.party
if not party or #party < 1 then
local t = "There is no POKéMON."
msg(t)
return false, "noparty", t
return false, "noparty", "There is no POKéMON."
end
if not partySlot then
return false, "need_slot", "Select a POKéMON."
end
local mon = party[partySlot]
if not mon then
local t = "There is no POKéMON."
msg(t)
return false, "noparty", t
return false, "noparty", "There is no POKéMON."
end
local ok = false
local text = nil
@@ -466,7 +442,7 @@ function ItemUse.useField(session, bag, id, partySlot)
if use == "tm" then
return ItemUse.useTm(session, bag, id, partySlot)
elseif use == "evo" then
ok, _, text = ItemUse.useEvolutionStone(session, mon, id)
ok, _, text = ItemUse.useEvolutionStone(session, mon, id, bag)
elseif use == "level" then
ok, _, text = ItemUse.useRareCandy(session, mon)
elseif use == "revive" then
@@ -495,13 +471,9 @@ function ItemUse.useField(session, bag, id, partySlot)
text = string.format("%s recovered\nfrom illness!", monName)
end
elseif use == "pp" then
local t = "It won't have any effect."
msg(t)
return false, "pp", t
return false, "pp", "It won't have any effect."
elseif use == "vitamin" then
local t = "It won't have any effect."
msg(t)
return false, "vitamin", t
return false, "vitamin", "It won't have any effect."
else
local healOk, restored = ItemUse.healMon(session, mon, id)
ok = healOk
@@ -514,17 +486,12 @@ function ItemUse.useField(session, bag, id, partySlot)
if ok then
Bag.remove(bag, id, 1)
if text then msg(text) end
return true, use, text or "It restored health!"
end
local noEff = "It won't have any effect."
msg(noEff)
return false, "noeffect", noEff
return false, "noeffect", text or "It won't have any effect."
end
local t = "OAK: This isn't the\ntime to use that!"
msg(t)
return false, "none", t
return false, "none", "OAK: This isn't the\ntime to use that!"
end
return ItemUse
+45 -20
View File
@@ -446,6 +446,8 @@ function Pokemon.moveName(moveId)
end
function Pokemon.learnset(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return {} end
if not Pokemon._learnsets then Pokemon.install(Pokemon._cache) end
@@ -453,6 +455,8 @@ function Pokemon.learnset(species)
end
function Pokemon.evolutions(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return {} end
if not Pokemon._evolutions then Pokemon.install(Pokemon._cache) end
@@ -460,6 +464,8 @@ function Pokemon.evolutions(species)
end
function Pokemon.dexEntry(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return nil end
if not Pokemon._dex then Pokemon.install(Pokemon._cache) end
@@ -477,26 +483,36 @@ end
--- FRLG GiveBoxMonInitialMoveset: learn all ≤ level; if full, drop first.
function Pokemon.movesAtLevel(species, level)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
level = tonumber(level) or 1
if not species then return {}, {} end
local list = Pokemon.learnset(species)
local moves, pp, maxPp = {}, {}, {}
for _, e in ipairs(list) do
local set = Pokemon.learnset(species)
local pool = {}
for _, e in ipairs(set) do
local lv = e[1] or e.level or 0
local mv = e[2] or e.move or 0
if lv > level then break end
if mv and mv > 0 then
if #moves >= 4 then
table.remove(moves, 1)
table.remove(pp, 1)
table.remove(maxPp, 1)
end
local row = Pokemon.battleMove(mv)
local max = (row and row.pp) or 5
moves[#moves + 1] = mv
pp[#pp + 1] = max
maxPp[#maxPp + 1] = max
local mv = tonumber(e[2] or e.move) or 0
if lv <= level and mv > 0 then
pool[#pool + 1] = mv
end
end
local moves = {}
local pp = {}
local maxPp = {}
for _, m in ipairs(pool) do
if #moves < 4 then
moves[#moves + 1] = m
local mpp = Pokemon.movePp(m)
pp[#pp + 1] = mpp
maxPp[#maxPp + 1] = mpp
else
table.remove(moves, 1)
table.remove(pp, 1)
table.remove(maxPp, 1)
moves[4] = m
local mpp = Pokemon.movePp(m)
pp[4] = mpp
maxPp[4] = mpp
end
end
return moves, pp, maxPp
@@ -504,6 +520,8 @@ end
--- Moves learned at exactly `level` (ROM learnset). Order preserved.
function Pokemon.movesLearnedAt(species, level)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
level = tonumber(level) or 0
local out = {}
@@ -651,7 +669,7 @@ function Pokemon.displayMonName(mon)
local nick = mon.nickname
if type(nick) == "string" and nick ~= "" then return nick end
if mon.name and mon.name ~= "" then return mon.name end
local sp = tonumber(mon.species or mon.speciesId)
local sp = Pokemon.speciesOf(mon)
return (sp and Pokemon.name(sp)) or "POKéMON"
end
@@ -767,9 +785,16 @@ function Pokemon.icon(species)
local w = (Pokemon._manifest and Pokemon._manifest.iconW) or Versions.MON_ICON_W or 32
local h = (Pokemon._manifest and Pokemon._manifest.iconH) or Versions.MON_ICON_H or 32
local rgba = read_rgba(species)
local image = image_from_rgba(rgba, w, h)
if not rgba then return nil end
local actualH = (#rgba >= w * (h * 2) * 4) and (h * 2) or h
local image = image_from_rgba(rgba, w, actualH)
if not image then return nil end
local entry = { image = image, w = w, h = h }
local frames = math.max(1, math.floor(actualH / h))
local quads = {}
for f = 0, frames - 1 do
quads[f] = love.graphics.newQuad(0, f * h, w, h, w, actualH)
end
local entry = { image = image, w = w, h = h, sheetH = actualH, frames = frames, quads = quads }
Pokemon._icons[species] = entry
return entry
end
+28
View File
@@ -288,7 +288,21 @@ function Adapters.host(mod, game, world)
local Hud = require("src.ui.game3.hud")
local g = resolveGame()
a.log("[game3] dialog via game3 HUD (not Gen2 showText)")
local gfxId = nil
local Space = package.loaded["src.core.game3.scripting.space"]
local Ctx = package.loaded["src.core.game3.scripting.context"]
local Objects = package.loaded["src.core.game3.objects"]
if Space and Space.vm and Space.vm.ctx and Ctx and Objects and Objects.get then
local lid = Space.vm.ctx.specialVars[Ctx.VAR_LAST_TALKED]
if lid and lid > 0 then
local obj = Objects.get(lid)
if obj and obj.def then
gfxId = obj.def.graphicsId or obj.def.gfx
end
end
end
Hud.openMessage(g, text, {
gfxId = gfxId,
done = function()
boxOpen = false
if done then done() end
@@ -321,7 +335,21 @@ function Adapters.host(mod, game, world)
local Hud = require("src.ui.game3.hud")
local g = resolveGame()
a.log("[game3] stay-dialog via game3 HUD")
local gfxId = nil
local Space = package.loaded["src.core.game3.scripting.space"]
local Ctx = package.loaded["src.core.game3.scripting.context"]
local Objects = package.loaded["src.core.game3.objects"]
if Space and Space.vm and Space.vm.ctx and Ctx and Objects and Objects.get then
local lid = Space.vm.ctx.specialVars[Ctx.VAR_LAST_TALKED]
if lid and lid > 0 then
local obj = Objects.get(lid)
if obj and obj.def then
gfxId = obj.def.graphicsId or obj.def.gfx
end
end
end
Hud.openMessageStay(g, text, {
gfxId = gfxId,
done = function()
if done then done() end
tick_vm()
+16 -5
View File
@@ -62,6 +62,8 @@ local function expand_seg(seg, ctx)
elseif t == "strvar" then
local sv = ctx and ctx.stringVars
return (sv and sv[seg.n]) or ""
elseif t == "tag" then
return seg.tag
elseif t == "ph" then
-- Cached extracts may still store FD 06 as { t="ph", code=6 }.
local code = tonumber(seg.code)
@@ -81,6 +83,10 @@ local function expand_seg(seg, ctx)
local sv = ctx and ctx.stringVars
return (sv and sv[n]) or ""
end
if name and (name == "FONT_MALE" or name == "FONT_FEMALE" or name == "FONT_NORMAL"
or name:find("^COLOR") or name:find("^SHADOW") or name:find("^HIGHLIGHT") or name:find("^BG")) then
return "{" .. name .. "}"
end
return ""
end
return nil
@@ -135,15 +141,17 @@ function TextIR.decode(bytes)
i = i + 2
elseif c == 0xFC then
flush_text(out, buf); buf = {}
-- EXT_CTRL: consume length-ish blindly (color=1 byte, pause=1, …).
local cmd = b(i + 1) or 0
local skip = 2
-- Common: FC 01 color, FC 08 pause, FC 04 wait SE — one arg byte each.
if cmd == 0x01 or cmd == 0x08 or cmd == 0x04 or cmd == 0x05
or cmd == 0x06 or cmd == 0x09 or cmd == 0x0A then
if cmd == 0x01 or cmd == 0x02 or cmd == 0x03 or cmd == 0x05 or cmd == 0x06
or cmd == 0x08 or cmd == 0x0C or cmd == 0x0D or cmd == 0x0E or cmd == 0x0F
or cmd == 0x11 or cmd == 0x12 or cmd == 0x13 or cmd == 0x14 then
skip = 3
elseif cmd == 0x04 or cmd == 0x0B or cmd == 0x10 then
skip = 5
if cmd == 0x0B or cmd == 0x10 then skip = 4 end
end
out[#out + 1] = { t = "ext", cmd = cmd }
out[#out + 1] = { t = "ext", cmd = cmd, raw = (type(bytes) == "string" and bytes:sub(i, i + skip - 1)) }
i = i + skip
else
local g = CHARMAP[c]
@@ -212,6 +220,9 @@ function TextIR.fromAscii(s)
out[#out + 1] = { t = "strvar", n = 2 }
elseif name == "STR_VAR_3" then
out[#out + 1] = { t = "strvar", n = 3 }
elseif name == "FONT_MALE" or name == "FONT_FEMALE" or name == "FONT_NORMAL"
or name:find("^COLOR") or name:find("^SHADOW") or name:find("^HIGHLIGHT") or name:find("^BG") then
out[#out + 1] = { t = "tag", tag = "{" .. name .. "}" }
else
out[#out + 1] = { t = "ph", name = name }
end
+17 -6
View File
@@ -194,12 +194,23 @@ function SummaryData.statusAilment(mon)
local curHp = tonumber(mon.hp or mon.currentHp)
if curHp and curHp <= 0 then return 7 end -- FNT
local st = tonumber(mon.status or mon.status1) or 0
if bit.band(st, 0x08) ~= 0 or bit.band(st, 0x80) ~= 0 then return 1 end -- PSN / TOXIC
if bit.band(st, 0x40) ~= 0 then return 2 end -- PRZ
if bit.band(st, 0x07) ~= 0 then return 3 end -- SLP
if bit.band(st, 0x20) ~= 0 then return 4 end -- FRZ
if bit.band(st, 0x10) ~= 0 then return 5 end -- BRN
local st = mon.status or mon.status1
if type(st) == "string" then
local s = st:upper()
if s:find("PSN") or s:find("POISON") or s:find("TOX") then return 1 end
if s:find("PRZ") or s:find("PAR") then return 2 end
if s:find("SLP") or s:find("SLEEP") then return 3 end
if s:find("FRZ") or s:find("FREEZE") or s:find("FROZEN") then return 4 end
if s:find("BRN") or s:find("BURN") then return 5 end
if s:find("PKRS") or s:find("POKERUS") then return 6 end
if s:find("FNT") or s:find("FAINT") then return 7 end
elseif type(st) == "number" then
if bit.band(st, 0x08) ~= 0 or bit.band(st, 0x80) ~= 0 then return 1 end -- PSN / TOXIC
if bit.band(st, 0x40) ~= 0 then return 2 end -- PRZ
if bit.band(st, 0x07) ~= 0 then return 3 end -- SLP
if bit.band(st, 0x20) ~= 0 then return 4 end -- FRZ
if bit.band(st, 0x10) ~= 0 then return 5 end -- BRN
end
if mon.pokerus and mon.pokerus > 0 then return 6 end -- PKRS
return 0
end
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -225,23 +225,27 @@ if encountersOnly then
end
if battleAnimsOnly then
local Rom = require("src.import.gba.rom")
local AnimExtract = require("src.import.gba.battle_anim_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting battle anim IR →", outRoot .. "/data/generated/gba/pokemon/battle_anims")
local detail = assert(AnimExtract.run({
cache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = "data/generated/gba/" .. path
end
return packCache:write(path, bytes)
end,
},
}))
local animCache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = "data/generated/gba/" .. path
end
return packCache:write(path, bytes)
end,
exists = function(_, rel) return packCache.exists and packCache:exists(rel) end,
read = function(_, rel) return packCache.read and packCache:read(rel) end,
}
local detail = assert(AnimExtract.run(rom, animCache, { cacheRoot = "data/generated/gba", force = true }))
rom:clearCache()
imports:_close()
print("OK battle anims", detail.moveCount, "→", detail.path)
os.exit(0)
+1 -1
View File
@@ -33,7 +33,7 @@ local ROLES = {
victoryWild = 311, victoryTrainer = 310, victoryGymLeader = 312,
encounterBoy = 285, encounterGirl = 284, encounterRival = 315, encounterRocket = 283,
encounterGymLeader = 342, pokeCenter = 303, heal = 256, surf = 305, cycling = 282,
caught = 322, caughtIntro = 319, evolution = 264, evolutionIntro = 263,
caught = 322, caughtIntro = 319, evolution = 264, evolutionIntro = 263, evolved = 259,
levelUp = 257, obtainItem = 258, followMe = 272, title = 278,
}
+32 -33
View File
@@ -426,17 +426,16 @@ function Extract.run(imports, cache, progressCb)
end
do
local AnimExtract = require("src.import.gba.battle_anim_extract")
AnimExtract.run({
cache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = Extract.CACHE_ROOT .. "/" .. path
end
return cache:write(path, bytes)
end,
},
})
local animCache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then path = Extract.CACHE_ROOT .. "/" .. path end
return cache:write(path, bytes)
end,
exists = function(_, rel) return (cache.exists and cache:exists(rel)) or false end,
read = function(_, rel) return (cache.read and cache:read(rel)) or nil end,
}
pcall(AnimExtract.run, rom2, animCache, { cacheRoot = Extract.CACHE_ROOT, force = true })
end
do
local BattleAiExtract = require("src.import.gba.battle_ai_extract")
@@ -1748,17 +1747,16 @@ local function _dormant_quantize_run(imports, cache, progressCb)
end
do
local AnimExtract = require("src.import.gba.battle_anim_extract")
AnimExtract.run({
cache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = Extract.CACHE_ROOT .. "/" .. path
end
return cache:write(path, bytes)
end,
},
})
local animCache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then path = Extract.CACHE_ROOT .. "/" .. path end
return cache:write(path, bytes)
end,
exists = function(_, rel) return (cache.exists and cache:exists(rel)) or false end,
read = function(_, rel) return (cache.read and cache:read(rel)) or nil end,
}
pcall(AnimExtract.run, rom2, animCache, { cacheRoot = Extract.CACHE_ROOT, force = true })
end
do
local BattleAiExtract = require("src.import.gba.battle_ai_extract")
@@ -2032,17 +2030,18 @@ function Extract.runNativeOnly(imports, cache, progressCb)
end
do
local AnimExtract = require("src.import.gba.battle_anim_extract")
AnimExtract.run({
cache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = Extract.CACHE_ROOT .. "/" .. path
end
return cache:write(path, bytes)
end,
},
})
local animCache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = Extract.CACHE_ROOT .. "/" .. path
end
return cache:write(path, bytes)
end,
exists = function(_, rel) return (cache.exists and cache:exists(rel)) or false end,
read = function(_, rel) return (cache.read and cache:read(rel)) or nil end,
}
pcall(AnimExtract.run, rom2, animCache, { cacheRoot = Extract.CACHE_ROOT, force = true })
end
do
local BattleAiExtract = require("src.import.gba.battle_ai_extract")
+130 -10
View File
@@ -38,6 +38,27 @@ local DEFAULT_SLOT_WIDE_EMPTY = string.char(
local STATUS_ICON_PATHS = {}
local BUTTON_PATHS = {
cancel = {
"pokefirered/graphics/party_menu/cancel_button.bin",
"graphics/party_menu/cancel_button.bin",
},
confirm = {
"pokefirered/graphics/party_menu/confirm_button.bin",
"graphics/party_menu/confirm_button.bin",
},
}
local DEFAULT_CANCEL_BUTTON = string.char(
0x0a, 0x10, 0x0b, 0x10, 0x0b, 0x10, 0x0b, 0x10, 0x0b, 0x10, 0x0b, 0x10, 0x0c, 0x10,
0x12, 0x10, 0x0b, 0x18, 0x0b, 0x18, 0x0b, 0x18, 0x0b, 0x18, 0x0b, 0x18, 0x13, 0x10
)
local DEFAULT_CONFIRM_BUTTON = string.char(
0x1b, 0x10, 0x1c, 0x10, 0x1c, 0x10, 0x1c, 0x10, 0x1c, 0x10, 0x1c, 0x10, 0x1d, 0x10,
0x23, 0x10, 0x1c, 0x18, 0x1c, 0x18, 0x1c, 0x18, 0x1c, 0x18, 0x1c, 0x18, 0x24, 0x10
)
local function default_cache_root()
local ok, Extract = pcall(require, "src.import.gba.extract_island1")
if ok and Extract and Extract.CACHE_ROOT then
@@ -148,12 +169,51 @@ local function bake_bg_rgba(gfx, palBytes, map, W, H)
return table.concat(chunks)
end
--- Blit slot tilemap (u8 tile ids) using party BG gfx + one pal bank.
-- Color 0 → transparent (window chrome).
local function bake_slot_rgba(gfx, palBytes, tilemap, tilesW, tilesH, palBank)
local W, H = tilesW * 8, tilesH * 8
local function build_party_box_pal(palBytes, selected)
local banks = load_pal_banks(palBytes, math.floor(#palBytes / 32))
local pal = banks[palBank] or banks[0] or {}
local base = banks[3] or banks[0] or {}
local pal = {}
for i = 0, 15 do pal[i] = base[i] or 0 end
local function get_pal_color(id)
local b = math.floor(id / 16)
local c = id % 16
return (banks[b] and banks[b][c]) or 0
end
if selected then
-- LOAD_PARTY_BOX_PAL(sPartyBoxCurrSelectionPalIds1, sPartyBoxPalOffsets1)
-- sPartyBoxCurrSelectionPalIds1 = {116, 117, 118}, sPartyBoxPalOffsets1 = {4, 5, 6}
pal[4] = get_pal_color(116)
pal[5] = get_pal_color(117)
pal[6] = get_pal_color(118)
-- LOAD_PARTY_BOX_PAL(sPartyBoxCurrSelectionPalIds2, sPartyBoxPalOffsets2)
-- sPartyBoxCurrSelectionPalIds2 = {97, 103, 104}, sPartyBoxPalOffsets2 = {1, 7, 8}
pal[1] = get_pal_color(97)
pal[7] = get_pal_color(103)
pal[8] = get_pal_color(104)
else
-- sPartyBoxEmptySlotPalIds1 = {52, 53, 54}, sPartyBoxPalOffsets1 = {4, 5, 6}
pal[4] = get_pal_color(52)
pal[5] = get_pal_color(53)
pal[6] = get_pal_color(54)
-- sPartyBoxEmptySlotPalIds2 = {49, 55, 56}, sPartyBoxPalOffsets2 = {1, 7, 8}
pal[1] = get_pal_color(49)
pal[7] = get_pal_color(55)
pal[8] = get_pal_color(56)
end
return pal
end
--- Blit slot tilemap (u8 tile ids) using party BG gfx + custom pal or pal bank.
-- Color 0 → transparent (window chrome).
local function bake_slot_rgba(gfx, palBytes, tilemap, tilesW, tilesH, customPalOrBank)
local W, H = tilesW * 8, tilesH * 8
local pal
if type(customPalOrBank) == "table" then
pal = customPalOrBank
else
local banks = load_pal_banks(palBytes, math.floor(#palBytes / 32))
pal = banks[customPalOrBank] or banks[0] or {}
end
local tileCount = math.floor(#gfx / 32)
local pixels = {}
for i = 1, W * H do pixels[i] = 0 end
@@ -182,6 +242,44 @@ local function bake_slot_rgba(gfx, palBytes, tilemap, tilesW, tilesH, palBank)
return table.concat(chunks), W, H
end
--- Blit button tilemap (16-bit tile entries) using party BG gfx + pal bank.
-- Color 0 → transparent.
local function bake_button_rgba(gfx, palBytes, tilemap16Bytes, tilesW, tilesH, palBank)
local W, H = tilesW * 8, tilesH * 8
local banks = load_pal_banks(palBytes, math.floor(#palBytes / 32))
local pal = (palBank and banks[palBank]) or banks[1] or {}
local tileCount = math.floor(#gfx / 32)
local pixels = {}
for i = 1, W * H do pixels[i] = 0 end
for ty = 0, tilesH - 1 do
for tx = 0, tilesW - 1 do
local idx = (ty * tilesW + tx) * 2 + 1
local entry = (tilemap16Bytes:byte(idx) or 0) + (tilemap16Bytes:byte(idx + 1) or 0) * 256
local tileId = entry % 1024
local hflip = math.floor(entry / 1024) % 2 == 1
local vflip = math.floor(entry / 2048) % 2 == 1
if tileId >= tileCount then tileId = 0 end
local tile = {}
local base = tileId * 32
for i = 1, 32 do tile[i] = gfx[base + i] or 0 end
decode_tile_4bpp(tile, pixels, tx * 8, ty * 8, W, hflip, vflip)
end
end
local chunks = {}
for i = 1, W * H do
local cIdx = pixels[i] or 0
if cIdx == 0 then
chunks[i] = string.char(0, 0, 0, 0)
else
local r, g, b = bgr555_to_rgb8(pal[cIdx] or 0)
chunks[i] = string.char(r, g, b, 255)
end
end
return table.concat(chunks), W, H
end
--- Two 32×32 frames (closed / open) stacked vertically.
local function bake_ball_sheet(gfx, palBytes)
local fw, fh = 32, 32
@@ -232,6 +330,9 @@ function PartyChromeExtract.run(rom, cache, opts)
local map = Lz77.decompress(get, Versions.PARTY_MENU_BG_TILEMAP)
cache:write(root .. "/bg.rgba", bake_bg_rgba(gfx, pal, map, W, H))
local palUnsel = build_party_box_pal(pal, false)
local palSel = build_party_box_pal(pal, true)
local mainBin = read_bin(SLOT_PATHS.main) or DEFAULT_SLOT_MAIN
local wideBin = read_bin(SLOT_PATHS.wide) or DEFAULT_SLOT_WIDE
local emptyBin = read_bin(SLOT_PATHS.empty) or DEFAULT_SLOT_WIDE_EMPTY
@@ -239,19 +340,38 @@ function PartyChromeExtract.run(rom, cache, opts)
error("party chrome: missing slot_main data")
end
if mainBin and #mainBin >= 70 then
-- Window pals 3–8 are remapped party-box colors; bank 3 is the blue unselected look.
local rgba = bake_slot_rgba(gfx, pal, mainBin, 10, 7, 3)
local rgba = bake_slot_rgba(gfx, pal, mainBin, 10, 7, palUnsel)
cache:write(root .. "/slot_main.rgba", rgba)
local rgbaSel = bake_slot_rgba(gfx, pal, mainBin, 10, 7, palSel)
cache:write(root .. "/slot_main_selected.rgba", rgbaSel)
end
if wideBin and #wideBin >= 54 then
local rgba = bake_slot_rgba(gfx, pal, wideBin, 18, 3, 3)
local rgba = bake_slot_rgba(gfx, pal, wideBin, 18, 3, palUnsel)
cache:write(root .. "/slot_wide.rgba", rgba)
local rgbaSel = bake_slot_rgba(gfx, pal, wideBin, 18, 3, palSel)
cache:write(root .. "/slot_wide_selected.rgba", rgbaSel)
end
if emptyBin and #emptyBin >= 54 then
local rgba = bake_slot_rgba(gfx, pal, emptyBin, 18, 3, 3)
local rgba = bake_slot_rgba(gfx, pal, emptyBin, 18, 3, palUnsel)
cache:write(root .. "/slot_wide_empty.rgba", rgba)
end
local cancelBin = read_bin(BUTTON_PATHS.cancel) or DEFAULT_CANCEL_BUTTON
if cancelBin and #cancelBin >= 28 then
local cancelRgba = bake_button_rgba(gfx, pal, cancelBin, 7, 2, 1)
cache:write(root .. "/cancel_button.rgba", cancelRgba)
local cancelRgbaSel = bake_button_rgba(gfx, pal, cancelBin, 7, 2, 2)
cache:write(root .. "/cancel_button_selected.rgba", cancelRgbaSel)
end
local confirmBin = read_bin(BUTTON_PATHS.confirm) or DEFAULT_CONFIRM_BUTTON
if confirmBin and #confirmBin >= 28 then
local confirmRgba = bake_button_rgba(gfx, pal, confirmBin, 7, 2, 1)
cache:write(root .. "/confirm_button.rgba", confirmRgba)
local confirmRgbaSel = bake_button_rgba(gfx, pal, confirmBin, 7, 2, 2)
cache:write(root .. "/confirm_button_selected.rgba", confirmRgbaSel)
end
local ballGfx = Lz77.decompress(get, Versions.PARTY_MENU_BALL_GFX)
local ballPal = Lz77.decompress(get, Versions.PARTY_MENU_BALL_PAL)
local ballRgba, bw, bh, frames = bake_ball_sheet(ballGfx, ballPal)
@@ -263,7 +383,7 @@ function PartyChromeExtract.run(rom, cache, opts)
end
local manifest = string.format(
"return {\n width = %d, height = %d,\n ballW = %d, ballSheetH = %d, ballFrames = %d,\n slotMainW = 80, slotMainH = 56,\n slotWideW = 144, slotWideH = 24,\n pokemonVersion = %d,\n}\n",
"return {\n width = %d, height = %d,\n ballW = %d, ballSheetH = %d, ballFrames = %d,\n slotMainW = 80, slotMainH = 56,\n slotWideW = 144, slotWideH = 24,\n cancelButtonW = 56, cancelButtonH = 16,\n pokemonVersion = %d,\n}\n",
W, H, bw, bh, frames or 2, Versions.POKEMON_VERSION or 1)
cache:write(root .. "/manifest.lua", manifest)
+14 -10
View File
@@ -285,13 +285,15 @@ end
local function write_manifest(count, version)
return string.format(
"return { magic = \"%s\", format = %d, pokemonVersion = %d, numSpecies = %d, iconW = %d, iconH = %d, abilitiesCount = %d, movesCount = %d }\n",
"return { magic = \"%s\", format = %d, pokemonVersion = %d, numSpecies = %d, iconW = %d, iconH = %d, iconSheetH = %d, iconFrames = %d, abilitiesCount = %d, movesCount = %d }\n",
PokemonExtract.MAGIC,
PokemonExtract.FORMAT_VERSION,
version or Versions.POKEMON_VERSION,
count,
Versions.MON_ICON_W,
Versions.MON_ICON_H,
Versions.MON_ICON_W or 32,
Versions.MON_ICON_H or 32,
64,
2,
Versions.ABILITIES_COUNT or 78,
Versions.MOVES_COUNT or 355)
end
@@ -427,11 +429,13 @@ function PokemonExtract.run(rom, cache, opts)
local nameBase = Versions.SPECIES_NAMES
local infoBase = Versions.SPECIES_INFO
local natBase = Versions.SPECIES_TO_NATIONAL
local iconTable = Versions.MON_ICON_TABLE
local w = Versions.MON_ICON_W or 32
local h = Versions.MON_ICON_H or 32
local iconH = h * 2 -- 64 (2 frames)
local iconBytes = Versions.MON_ICON_BYTES or math.floor(w * iconH / 2) -- 1024 for 32x64
local palIdxBase = Versions.MON_ICON_PAL_INDICES
local pals = load_icon_pals(rom)
local w, h = Versions.MON_ICON_W, Versions.MON_ICON_H
local frameBytes = math.floor(w * h / 2) -- 512 for 32x32
local iconTable = Versions.MON_ICON_TABLE
for sp = 0, num - 1 do
if progress and sp % 40 == 0 then
@@ -471,11 +475,11 @@ function PokemonExtract.run(rom, cache, opts)
local pal = pals[palIdx] or pals[0]
local rgba
if off then
local bytes = rom:readBytes(off, frameBytes)
local pixels = decode_4bpp(bytes, w, h)
rgba = bake_icon_rgba(pixels, pal, w, h)
local bytes = rom:readBytes(off, iconBytes)
local pixels = decode_4bpp(bytes, w, iconH)
rgba = bake_icon_rgba(pixels, pal, w, iconH)
else
rgba = string.rep(string.char(0, 0, 0, 0), w * h)
rgba = string.rep(string.char(0, 0, 0, 0), w * iconH * 4)
end
cache:write(root .. "/icons/" .. sp .. ".rgba", rgba)
end
@@ -165,6 +165,7 @@ local function load_base_tilemap(kind)
local list = names[kind] or names.info
local candidates = {}
for _, n in ipairs(list) do
candidates[#candidates + 1] = "pokefirered/graphics/summary_screen/" .. n
candidates[#candidates + 1] = "data/generated/gba/pokemon/summary/" .. n
end
return read_bin(candidates)
+477 -2
View File
@@ -120,8 +120,483 @@ Versions.BATTLE_MOVES = 0x250C04
Versions.BATTLE_MOVE_SIZE = 12
Versions.MOVES_COUNT = 355 -- MOVE_NONE .. last Gen3 move id inclusive span
-- Battle anim IR pack (transpile from pret scripts + tag sheets).
Versions.BATTLE_ANIMS_VERSION = 2
-- Battle anim IR pack — ROM-native bytecode extraction (FireRed USA 1.0).
-- Addresses verified by structural scan of the ROM.
Versions.BATTLE_ANIMS_VERSION = 3
Versions.BATTLE_ANIMS = {
-- gBattleAnims_Moves: 355 GBA pointers to bytecode scripts (one per move id 0..354).
moves_table = 0x1C68F4,
move_count = 355,
-- gBattleAnimPicTable: 289 CompressedSpriteSheet entries, stride 8 (ptr+size).
-- pic_ptr = rom:u32(pic_table + tag_idx*8), palette = rom:u32(pal_table + tag_idx*8)
pic_table = 0x3ACC08,
pal_table = 0x3AD510,
tag_count = 289, -- ANIM_SPRITES_START+0 .. ANIM_SPRITES_START+288
sprites_start = 10000, -- ANIM_SPRITES_START constant
}
-- ANIM_TAG_* index → name string (from pokefirered/include/constants/battle_anim.h).
-- Indices not in this list are unused/reserved (false = skip extraction).
Versions.ANIM_TAG_NAMES = {
[0]="BONE",[1]="SPARK",[2]="PENCIL",[3]="AIR_WAVE",[4]="ORB",[5]="SWORD",
[6]="SEED",[7]="EXPLOSION_6",[8]="PINK_ORB",[9]="GUST",[10]="ICE_CUBE",
[11]="SPARK_2",[12]="ORANGE",[13]="YELLOW_BALL",[14]="LOCK_ON",[15]="TIED_BAG",
[16]="BLACK_SMOKE",[17]="BLACK_BALL",[18]="CONVERSION",[19]="GLASS",
[20]="HORN_HIT",[21]="HIT",[22]="HIT_2",[23]="BLUE_SHARDS",[24]="CLOSING_EYE",
[25]="WAVING_HAND",[26]="HIT_DUPLICATE",[27]="LEER",[28]="BLUE_BURST",
[29]="SMALL_EMBER",[30]="GRAY_SMOKE",[31]="BLUE_STAR",[32]="BUBBLE_BURST",
[33]="FIRE",[34]="SPINNING_FIRE",[35]="FIRE_PLUME",[36]="LIGHTNING_2",
[37]="LIGHTNING",[38]="CLAW_SLASH_2",[39]="CLAW_SLASH",[40]="SCRATCH_3",
[41]="SCRATCH_2",[42]="BUBBLE_BURST_2",[43]="ICE_CHUNK",[44]="GLASS_2",
[45]="PINK_HEART_2",[46]="SAP_DRIP",[47]="SAP_DRIP_2",[48]="SPARKLE_1",
[49]="SPARKLE_2",[50]="HUMANOID_FOOT",[51]="MONSTER_FOOT",[52]="HUMANOID_HAND",
[53]="NOISE_LINE",[54]="YELLOW_UNK",[55]="RED_FIST",[56]="SLAM_HIT",[57]="RING",
[58]="ROCKS",[59]="Z",[60]="YELLOW_UNK_2",[61]="AIR_SLASH",[62]="SPINNING_GREEN_ORBS",
[63]="LEAF",[64]="FINGER",[65]="POISON_POWDER",[66]="BROWN_TRIANGLE",
[67]="SLEEP_POWDER",[68]="STUN_SPORE",[69]="POWDER",[70]="SPARKLE_3",
[71]="SPARKLE_4",[72]="MUSIC_NOTES",[73]="DUCK",[74]="MUD_SAND",[75]="ALERT",
[76]="BLUE_FLAMES",[77]="BLUE_FLAMES_2",[78]="SHOCK_4",[79]="SHOCK",
[80]="BELL_2",[81]="PINK_GLOVE",[82]="BLUE_LINES",[83]="IMPACT_3",[84]="IMPACT_2",
[85]="RETICLE",[86]="BREATH",[87]="ANGER",[88]="SNOWBALL",[89]="VINE",
[90]="SWORD_2",[91]="CLAPPING",[92]="RED_TUBE",[93]="AMNESIA",[94]="STRING_2",
[95]="PENCIL_2",[96]="PETAL",[97]="BENT_SPOON",[98]="WEB",[99]="MILK_BOTTLE",
[100]="COIN",[101]="CRACKED_EGG",[102]="HATCHED_EGG",[103]="FRESH_EGG",
[104]="FANGS",[105]="EXPLOSION_2",[106]="EXPLOSION_3",[107]="WATER_DROPLET",
[108]="WATER_DROPLET_2",[109]="SEED_2",[110]="SPROUT",[111]="RED_WAND",
[112]="PURPLE_GREEN_UNK",[113]="WATER_COLUMN",[114]="MUD_UNK",[115]="RAIN_DROPS",
[116]="FURY_SWIPES",[117]="VINE_2",[118]="TEETH",[119]="BONE_2",[120]="WHITE_BAG",
[121]="UNKNOWN",[122]="PURPLE_CORAL",[123]="PURPLE_DROPLET",[124]="SHOCK_2",
[125]="CLOSING_EYE_2",[126]="METAL_BALL",[127]="MONSTER_DOLL",[128]="WHIRLWIND",
[129]="WHIRLWIND_2",[130]="EXPLOSION_4",[131]="EXPLOSION_5",[132]="TONGUE",
[133]="SMOKE",[134]="SMOKE_2",[135]="IMPACT",[136]="CIRCLE_IMPACT",[137]="SCRATCH",
[138]="CUT",[139]="SHARP_TEETH",[140]="RAINBOW_RINGS",[141]="ICE_CRYSTALS",
[142]="ICE_SPIKES",[143]="HANDS_AND_FEET",[144]="MIST_CLOUD",[145]="CLAMP",
[146]="BUBBLE",[147]="ORBS",[148]="WATER_IMPACT",[149]="WATER_ORB",
[150]="POISON_BUBBLE",[151]="TOXIC_BUBBLE",[152]="SPIKES",[153]="HORN_HIT_2",
[154]="AIR_WAVE_2",[155]="SMALL_BUBBLES",[156]="ROUND_SHADOW",[157]="SUNLIGHT",
[158]="SPORE",[159]="FLOWER",[160]="RAZOR_LEAF",[161]="NEEDLE",
[162]="WHIRLWIND_LINES",[163]="GOLD_RING",[164]="PURPLE_RING",[165]="BLUE_RING",
[166]="GREEN_LIGHT_WALL",[167]="BLUE_LIGHT_WALL",[168]="RED_LIGHT_WALL",
[169]="GRAY_LIGHT_WALL",[170]="ORANGE_LIGHT_WALL",[171]="BLACK_BALL_2",
[172]="PURPLE_GAS_CLOUD",[173]="SPARK_H",[174]="YELLOW_STAR",[175]="LARGE_FRESH_EGG",
[176]="SHADOW_BALL",[177]="LICK",[178]="VOID_LINES",[179]="STRING",
[180]="WEB_THREAD",[181]="SPIDER_WEB",[182]="LIGHTBULB",[183]="SLASH",
[184]="FOCUS_ENERGY",[185]="SPHERE_TO_CUBE",[186]="TENDRILS",[187]="EYE",
[188]="WHITE_SHADOW",[189]="TEAL_ALERT",[190]="OPENING_EYE",
[191]="ROUND_WHITE_HALO",[192]="FANG_ATTACK",[193]="PURPLE_HAND_OUTLINE",
[194]="MOON",[195]="GREEN_SPARKLE",[196]="SPIRAL",[197]="SNORE_Z",
[198]="EXPLOSION",[199]="NAIL",[200]="GHOSTLY_SPIRIT",[201]="WARM_ROCK",
[202]="BREAKING_EGG",[203]="THIN_RING",[204]="PUNCH_IMPACT",[205]="BELL",
[206]="MUSIC_NOTES_2",[207]="SPEED_DUST",[208]="TORN_METAL",[209]="THOUGHT_BUBBLE",
[210]="MAGENTA_HEART",[211]="ELECTRIC_ORBS",[212]="CIRCLE_OF_LIGHT",
[213]="ELECTRICITY",[214]="FINGER_2",[215]="MOVEMENT_WAVES",[216]="RED_HEART",
[217]="RED_ORB",[218]="EYE_SPARKLE",[219]="PINK_HEART",[220]="ANGEL",
[221]="DEVIL",[222]="SWIPE",[223]="ROOTS",[224]="ITEM_BAG",
[225]="JAGGED_MUSIC_NOTE",[226]="POKEBALL",[227]="SPOTLIGHT",[228]="LETTER_Z",
[229]="RAPID_SPIN",[230]="TRI_ATTACK_TRIANGLE",[231]="WISP_ORB",[232]="WISP_FIRE",
[233]="GOLD_STARS",[234]="ECLIPSING_ORB",[235]="GRAY_ORB",[236]="BLUE_ORB",
[237]="RED_ORB_2",[238]="PINK_PETAL",[239]="PAIN_SPLIT",[240]="CONFETTI",
[241]="GREEN_STAR",[242]="PINK_CLOUD",[243]="SWEAT_DROP",[244]="GUARD_RING",
[245]="PURPLE_SCRATCH",[246]="PURPLE_SWIPE",[247]="TAG_HAND",[248]="SMALL_RED_EYE",
[249]="HOLLOW_ORB",[250]="X_SIGN",[251]="BLUEGREEN_ORB",[252]="PAW_PRINT",
[253]="PURPLE_FLAME",[254]="RED_BALL",[255]="SMELLINGSALT_EFFECT",[256]="METEOR",
[257]="FLAT_ROCK",[258]="MAGNIFYING_GLASS",[259]="BROWN_ORB",
[260]="METAL_SOUND_WAVES",[261]="FLYING_DIRT",[262]="ICICLE_SPEAR",[263]="HAIL",
[264]="GLOWY_RED_ORB",[265]="GLOWY_GREEN_ORB",[266]="GREEN_SPIKE",
[267]="WHITE_CIRCLE_OF_LIGHT",[268]="GLOWY_BLUE_ORB",[269]="SAFARI_BAIT",
[270]="WHITE_FEATHER",[271]="SPARKLE_6",[272]="SPLASH",[273]="SWEAT_BEAD",
[274]="GEM_1",[275]="GEM_2",[276]="GEM_3",[277]="SLAM_HIT_2",[278]="RECYCLE",
[279]="RED_PARTICLES",[280]="PROTECT",[281]="DIRT_MOUND",[282]="SHOCK_3",
[283]="WEATHER_BALL",[284]="BIRD",[285]="CROSS_IMPACT",[286]="SLASH_2",
[287]="WHIP_HIT",[288]="BLUE_RING_2",
}
Versions.ANIM_CALLBACK_NAMES = {
[0x08075D9D] = "SpriteOnMonPos",
[0x08075DF5] = "TranslateAnimSpriteToTargetMonLocation",
[0x08075E81] = "ThrowProjectile",
[0x08075F0D] = "TravelDiagonally",
[0x08076FD1] = "SpinningSparkle",
[0x080990AD] = "DoHorizontalLunge",
[0x08099145] = "DoVerticalDip",
[0x080991B5] = "SlideMonToOriginalPos",
[0x080992E1] = "SlideMonToOffset",
[0x080A22E9] = "MovePowderParticle",
[0x080A2389] = "PowerAbsorptionOrb",
[0x080A2581] = "AbsorptionOrb",
[0x080A26F1] = "LeechSeed",
[0x080A27D1] = "SporeParticle",
[0x080A2921] = "PetalDanceBigFlower",
[0x080A29ED] = "PetalDanceSmallFlower",
[0x080A2AA5] = "SpriteOnMonPos",
[0x080A2B9D] = "TranslateLinearSingleSineWave",
[0x080A2D11] = "MoveTwisterParticle",
[0x080A2E29] = "ConstrictBinding",
[0x080A3169] = "IngrainRoot",
[0x080A31ED] = "FrenzyPlantRoot",
[0x080A3335] = "IngrainOrb",
[0x080A3519] = "Present",
[0x080A37BD] = "TrickBag",
[0x080A4041] = "FlyingParticle",
[0x080A4299] = "NeedleArmSpike",
[0x080A4451] = "WhipHit",
[0x080A44E1] = "CuttingSlice",
[0x080A4589] = "AirCutterSlice",
[0x080A48F1] = "Protect",
[0x080A4ACD] = "MilkBottle",
[0x080A4D0D] = "GrantingStars",
[0x080A4D5D] = "SparklingStars",
[0x080A4EF5] = "SleepLetterZ",
[0x080A4FAD] = "LockOnTarget",
[0x080A5299] = "LockOnMoveTarget",
[0x080A5341] = "BowMon",
[0x080A5941] = "SlashSlice",
[0x080A59A9] = "FalseSwipeSlice",
[0x080A59F1] = "FalseSwipePositionedSlice",
[0x080A5AD9] = "EndureEnergy",
[0x080A5B7D] = "SharpenSphere",
[0x080A5C69] = "Conversion",
[0x080A5D4D] = "Conversion2",
[0x080A5EE1] = "Moon",
[0x080A5F41] = "MoonlightSparkle",
[0x080A6245] = "HornHit",
[0x080A65CD] = "SuperFang",
[0x080A68B1] = "FlyingMusicNotes",
[0x080A69B9] = "BellyDrumHand",
[0x080A6A29] = "SlowFlyingMusicNotes",
[0x080A6B65] = "ThoughtBubble",
[0x080A6C09] = "MetronomeFinger",
[0x080A6C85] = "FollowMeFinger",
[0x080A6D91] = "TauntFinger",
[0x080A71D9] = "KinesisZapEnergy",
[0x080A727D] = "SwordsDanceBlade",
[0x080A72C9] = "SonicBoomProjectile",
[0x080A7A89] = "CoinThrow",
[0x080A7B3D] = "FallingCoin",
[0x080A7BC5] = "CuttingSlice",
[0x080A7D65] = "ViceGripPincer",
[0x080A7E15] = "GuillotinePincer",
[0x080A851D] = "BreathPuff",
[0x080A85AD] = "AngerMark",
[0x080A8A1D] = "Pencil",
[0x080A8BC5] = "BlendThinRing",
[0x080A8EE9] = "UproarRing",
[0x080A8F39] = "SoftBoiledEgg",
[0x080A97E9] = "HealBellMusicNote",
[0x080A9861] = "RazorLeafParticle",
[0x080A9B41] = "RedHeartProjectile",
[0x080A9BC5] = "ParticleBurst",
[0x080A9C4D] = "RedHeartRising",
[0x080AA175] = "OrbitFast",
[0x080AA2B1] = "OrbitScatter",
[0x080AA37D] = "SpitUpOrb",
[0x080AA3F1] = "EyeSparkle",
[0x080AA409] = "Angel",
[0x080AA509] = "PinkHeart",
[0x080AA58D] = "Devil",
[0x080AA6B9] = "FurySwipes",
[0x080AA709] = "MovementWaves",
[0x080AA839] = "JaggedMusicNote",
[0x080AA939] = "PerishSongMusicNote2",
[0x080AA999] = "PerishSongMusicNote",
[0x080AAAE5] = "GuardRing",
[0x080AAC99] = "WaterBubbleProjectile",
[0x080AB2CD] = "WaterGunDroplet",
[0x080AB309] = "SmallBubblePair",
[0x080AC625] = "WaterPulseBubble",
[0x080AC6D9] = "WaterPulseRing",
[0x080AC90D] = "FireSpiralInward",
[0x080AC991] = "MeteorMashStar",
[0x080ACA01] = "LargeFlame",
[0x080AD455] = "EruptionFallingRock",
[0x080AD541] = "WillOWispOrb",
[0x080AD6F5] = "WillOWispFire",
[0x080ADBED] = "Lightning",
[0x080ADD4D] = "SparkElectricity",
[0x080ADEB1] = "ZapCannonSpark",
[0x080AE001] = "ThunderboltOrb",
[0x080AE06D] = "SparkElectricityFlashing",
[0x080AE471] = "ThunderWave",
[0x080AE71D] = "GrowingChargeOrb",
[0x080AE775] = "ElectricPuff",
[0x080AE7DD] = "VoltTackleOrbSlide",
[0x080AEC81] = "GrowingShockWaveOrb",
[0x080AF2F1] = "IcePunchSwirlingParticle",
[0x080AF331] = "IceBeamParticle",
[0x080AFD4D] = "ThrowMistBall",
[0x080AFFD5] = "InitPoisonGasCloudAnim",
[0x080B08DD] = "SlideHandOrFootToTarget",
[0x080B0929] = "JumpKick",
[0x080B0955] = "BasicFistOrFoot",
[0x080B09A5] = "FistOrFootRandomPos",
[0x080B0B81] = "CrossChopHand",
[0x080B0C29] = "SlidingKick",
[0x080B0CED] = "SpinningKickOrPunch",
[0x080B0D59] = "StompFoot",
[0x080B0DF1] = "DizzyPunchDuck",
[0x080B107D] = "SuperpowerOrb",
[0x080B1189] = "SuperpowerRock",
[0x080B12E9] = "SuperpowerFireball",
[0x080B13F9] = "ArmThrustHit",
[0x080B1485] = "RevengeScratch",
[0x080B1621] = "SludgeProjectile",
[0x080B16A1] = "AcidPoisonBubble",
[0x080B1745] = "SludgeBombHitParticle",
[0x080B17C5] = "AcidPoisonDroplet",
[0x080B18E5] = "EllipticalGust",
[0x080B1A1D] = "GustToTarget",
[0x080B1D89] = "FallingFeather",
[0x080B2781] = "WhirlwindLine",
[0x080B2BD9] = "FlyingParticle",
[0x080B2ECD] = "DefensiveWall",
[0x080B31D1] = "WallSparkle",
[0x080B3279] = "BentSpoon",
[0x080B32F5] = "QuestionMark",
[0x080B37ED] = "RedX",
[0x080B3E85] = "PsychoBoost",
[0x080B407D] = "LeechLifeNeedle",
[0x080B41F9] = "StringWrap",
[0x080B42C1] = "SpiderWeb",
[0x080B4365] = "TranslateStinger",
[0x080B4495] = "BlockX",
[0x080B45D9] = "TailGlowOrb",
[0x080B4635] = "FallingRock",
[0x080B46F9] = "RockFragment",
[0x080B477D] = "ParticleInVortex",
[0x080B4AA9] = "FlyingSandCrescent",
[0x080B4B8D] = "RaiseSprite",
[0x080B4FE5] = "RockTomb",
[0x080B5075] = "RockBlastRock",
[0x080B5269] = "ConfuseRayBallBounce",
[0x080B5451] = "ConfuseRayBallSpiral",
[0x080B563D] = "ShadowBall",
[0x080B57F9] = "Lick",
[0x080B725D] = "OutrageFlame",
[0x080B73AD] = "DragonRageFirePlume",
[0x080B741D] = "DragonFireToTarget",
[0x080B7449] = "DragonDanceOrb",
[0x080B77E5] = "OverheatFlame",
[0x080B7BD5] = "Bite",
[0x080B7C89] = "TearDrop",
[0x080B86B1] = "ClawSlash",
[0x080B8B6D] = "BonemerangProjectile",
[0x080B8C55] = "BoneHitProjectile",
[0x080B8D59] = "MudSportDirt",
[0x080B99D5] = "GrantingStars",
[0x080B9A7D] = "ComplexPaletteBlend",
[0x080BA27D] = "ShakeMonOrBattleTerrain",
[0x080BA561] = "HitSplatBasic",
[0x080BA5A9] = "HitSplatPersistent",
[0x080BA5F9] = "HitSplatHandleInvert",
[0x080BA631] = "HitSplatRandom",
[0x080BA739] = "CrossImpact",
[0x080BA781] = "FlashingHitSplat",
[0x080DE2C1] = "BlackSmoke",
[0x080DE39D] = "WhiteHalo",
[0x080DE441] = "TealAlert",
[0x080DE4DD] = "MeanLookEye",
[0x080DE8B1] = "Spikes",
[0x080DE99D] = "Leer",
[0x080DE9D9] = "LetterZ",
[0x080DEA99] = "Fang",
[0x080DEB21] = "Spotlight",
[0x080DEC91] = "ClappingHand",
[0x080DEDBD] = "ClappingHand2",
[0x080DEEBD] = "RapidSpin",
[0x080DF469] = "TriAttackTriangle",
[0x080DF581] = "BatonPassPokeball",
[0x080DF8F9] = "SwallowBlueOrb",
[0x080DFEDD] = "GreenStar",
[0x080E04E1] = "WeakFrustrationAngerMark",
[0x080E0791] = "SweetScentPetal",
[0x080E0A3D] = "PainSplitProjectile",
[0x080E0C69] = "FlatterConfetti",
[0x080E0D75] = "FlatterSpotlight",
[0x080E0E95] = "ReversalOrb",
[0x080E186D] = "YawnCloud",
[0x080E20D5] = "RoarNoiseLine",
[0x080E24E1] = "AssistPawprint",
[0x080E2775] = "SmellingSaltsHand",
[0x080E29F1] = "SmellingSaltExclamation",
[0x080E2AB1] = "HelpingHandClap",
[0x080E2F15] = "ForesightMagnifyingGlass",
[0x080E34D1] = "BlockX",
[0x080E4335] = "KnockOffStrike",
[0x080E43A5] = "Recycle",
}
Versions.ANIM_TASK_NAMES = {
[0x03280400] = "ShakeTargetInPattern",
[0x08076049] = "AlphaFadeIn",
[0x0807616D] = "BlendMonInAndOut",
[0x08076289] = "ShockWaveProgressingBolt",
[0x080766B9] = "GetFrustrationPowerLevel",
[0x08077031] = "MoveSkyUppercutBg",
[0x080783FD] = "ShakeMon2",
[0x080989F9] = "ShakeMon",
[0x08098B1D] = "ShakeMon2",
[0x08098CD1] = "ShakeMonInPlace",
[0x08098E91] = "ShakeAndSinkMon",
[0x08098F85] = "TranslateMonElliptical",
[0x0809907D] = "MetallicShine",
[0x0809949D] = "WindUpLunge",
[0x080995FD] = "SlideOffScreen",
[0x08099705] = "SwayMon",
[0x080998B1] = "ScaleMonAndRestore",
[0x08099981] = "RotateMonSpriteToSide",
[0x08099A79] = "RotateMonToSideAndRestore",
[0x08099BD5] = "ShakeTargetBasedOnMovePowerOrDmg",
[0x080A28C5] = "SporeDoubleBattle",
[0x080A39C1] = "ShakeMon",
[0x080A41C5] = "BlendBattleAnimPalExclude",
[0x080A5CD5] = "ConversionAlphaBlend",
[0x080A5DE1] = "Conversion2AlphaBlend",
[0x080A5FC1] = "MoonlightEndFade",
[0x080A63B5] = "DoubleTeam",
[0x080A65E9] = "BlendNonAttackerPalettes",
[0x080A66A1] = "MusicNotesClearRainbowBlend",
[0x080A70A1] = "Withdraw",
[0x080A76F1] = "OdorSleuthMovement",
[0x080A7FB1] = "GrowAndGrayscale",
[0x080A8075] = "Minimize",
[0x080A8339] = "Splash",
[0x080A84B5] = "ShakeMon",
[0x080A8639] = "ThrashMoveMonHorizontal",
[0x080A86A5] = "ThrashMoveMonVertical",
[0x080A8875] = "SketchDrawMon",
[0x080A96B5] = "LoadMusicNotesPals",
[0x080A9761] = "FreeMusicNotesPals",
[0x080A98B1] = "FakeOut",
[0x080A9A21] = "StretchTargetUp",
[0x080A9AB1] = "StretchAttackerUp",
[0x080A9CE9] = "HeartsBackground",
[0x080A9F11] = "ScaryFace",
[0x080AA7C9] = "UproarDistortion",
[0x080AAB7D] = "IsFuryCutterHitRight",
[0x080AABA1] = "GetFuryCutterHitCount",
[0x080AABC1] = "CreateRaindrops",
[0x080AAF61] = "RotateAuroraRingColors",
[0x080AB101] = "ShakeMon2",
[0x080AB38D] = "CreateSurfWave",
[0x080ABB29] = "AttackerPunchWithTrace",
[0x080AC00D] = "ShakeMon",
[0x080AC329] = "LeafBlade",
[0x080ACEA5] = "EruptionLaunchRocks",
[0x080ADAA5] = "BlendBackground",
[0x080ADAD9] = "ShakeTargetInPattern",
[0x080AE221] = "ElectricBolt",
[0x080AE541] = "ScaleMonAndRestore",
[0x080AE8A1] = "VoltTackleAttackerReappear",
[0x080AEA11] = "VoltTackleBolt",
[0x080AECE1] = "ShockWaveProgressingBolt",
[0x080AEFA1] = "ShockWaveLightning",
[0x080AFAE5] = "HazeScrollingFog",
[0x080AFD81] = "MistBallFog",
[0x080B038D] = "Hail",
[0x080B0871] = "GetRolloutCounter",
[0x080B1531] = "FrozenIceCube",
[0x080B194D] = "AnimateGustTornadoPalette",
[0x080B2869] = "DrillPeckHitSplats",
[0x080B3419] = "MeditateStretchAttacker",
[0x080B3481] = "Teleport",
[0x080B3585] = "ImprisonOrbs",
[0x080B3835] = "SkillSwap",
[0x080B3A59] = "ExtrasensoryDistortion",
[0x080B3C79] = "TransparentCloneGrowAndShrink",
[0x080B4811] = "LoadSandstormBackground",
[0x080B4BD1] = "Rollout",
[0x080B5149] = "GetSeismicTossDamageLevel",
[0x080B5189] = "MoveSeismicTossBg",
[0x080B51ED] = "SeismicTossBgAccelerateDownAtEnd",
[0x080B54E9] = "NightShadeClone",
[0x080B58AD] = "NightmareClone",
[0x080B5AAD] = "SpiteTargetShadow",
[0x080B6021] = "DestinyBondWhiteShadow",
[0x080B68C9] = "GrudgeFlames",
[0x080B75E1] = "ElectricChargingParticles",
[0x080B78E1] = "AttackerFadeToInvisible",
[0x080B79DD] = "AttackerFadeFromInvisible",
[0x080B7A81] = "InitAttackerFadeFromInvisible",
[0x080B7DA5] = "MoveAttackerMementoShadow",
[0x080B8071] = "MoveTargetMementoShadow",
[0x080B85B9] = "InitMementoShadow",
[0x080B8665] = "MementoHandleBg",
[0x080B86ED] = "BlendBattleAnimPal",
[0x080B8A75] = "ShakeMon2",
[0x080B8B39] = "GetIsDoomDesireHitTurn",
[0x080B94B5] = "HorizontalShake",
[0x080B97D9] = "IsPowerOver99",
[0x080B9801] = "PositionFissureBgOnBattler",
[0x080B9BDD] = "InvertScreenColor",
[0x080B9CE5] = "BlendColorCycleExclude",
[0x080B9E59] = "BlendColorCycleByTag",
[0x080B9F6D] = "FlashAnimTagWithColor",
[0x080BA0E9] = "ShakeMon2",
[0x080BA47D] = "ShakeMon2",
[0x080BA7F9] = "WaterSport",
[0x080BA83D] = "DragonDanceWaver",
[0x080BA935] = "SetCamouflageBlend",
[0x080BAA21] = "AllocBackupPalBuffer",
[0x080BAB39] = "HardwarePaletteFade",
[0x080BAB99] = "TraceMonBlended",
[0x080BB661] = "Flash",
[0x080BB7DD] = "StartSinAnimTimer",
[0x080BB82D] = "ShakeMon",
[0x080BB921] = "GetAttackerSide",
[0x080BB94D] = "MusicNotesClearRainbowBlend",
[0x080BB9B1] = "BlendPalInAndOutByTag",
[0x080BBDF1] = "GetBattleTerrain",
[0x080BBE11] = "ShakeBattleTerrain",
[0x080BBE3D] = "FreeBackupPalBuffer",
[0x080BBE6D] = "ShakeBattleTerrain",
[0x080BBF09] = "CopyPalUnfadedFromBackup",
[0x080BBFA5] = "ShakeBattleTerrain",
[0x080BC02D] = "IsContest",
[0x080BC12D] = "SetAttackerInvisibleWaitForSignal",
[0x080DCE11] = "SoundTask_FireBlast",
[0x080DCF39] = "SoundTask_LoopSEAdjustPanning",
[0x080DD06D] = "SoundTask_PlayCryHighPitch",
[0x080DD149] = "SoundTask_PlayDoubleCry",
[0x080DD2F5] = "SoundTask_WaitForCry",
[0x080DD3DD] = "SoundTask_PlaySE1WithPanning",
[0x080DD411] = "ShakeMon",
[0x080DD445] = "SoundTask_AdjustPanningVar",
[0x080DE34D] = "SmokescreenImpact",
[0x080DE6F1] = "SetPsychicBackground",
[0x080DE7B5] = "FadeScreenToWhite",
[0x080DEAB5] = "IsTargetPlayerSide",
[0x080DEAF1] = "IsHealingMove",
[0x080DEDD9] = "CreateSpotlight",
[0x080DEE79] = "RemoveSpotlight",
[0x080DEF9D] = "RapinSpinMonElevation",
[0x080DF1DD] = "TormentAttacker",
[0x080DF525] = "DefenseCurlDeformMon",
[0x080DF849] = "StockpileDeformMon",
[0x080DF8A1] = "SpitUpDeformMon",
[0x080DF965] = "SwallowDeformMon",
[0x080DF9BD] = "TransformMon",
[0x080DFC51] = "MorningSunLightBeam",
[0x080E0559] = "CycleMagicalLeafPal",
[0x080E0851] = "FlailMovement",
[0x080E0B01] = "PainSplitMovement",
[0x080E0FB9] = "RolePlaySilhouette",
[0x080E12F9] = "AcidArmor",
[0x080E1705] = "ShakeMon",
[0x080E1D5D] = "SquishAndSweatDroplets",
[0x080E1FC5] = "FacadeColorBlend",
[0x080E2085] = "StatusClearedEffect",
[0x080E21CD] = "GlareEyeDots",
[0x080E2519] = "BarrageBall",
[0x080E28DD] = "SmellingSaltsSquish",
[0x080E2CE5] = "HelpingHandAttackerMovement",
[0x080E3295] = "MonToSubstitute",
[0x080E3665] = "BlendBattleAnimPalExclude",
[0x080E38D9] = "GetReturnPowerLevel",
[0x080E4161] = "TeeterDanceMovement",
[0x080E44ED] = "GetWeather",
[0x080E4541] = "ShakeMon",
[0x8B0E081D] = "BlendBattleAnimPal",
}
-- Wild encounters (FireRed USA 1.0): gWildMonHeaders[] — 20-byte entries,
-- terminated by mapGroup/mapNum = 0xFF. Verified via Route 1 land fingerprint.
+33
View File
@@ -212,6 +212,39 @@ function BattleChrome.drawTerrain(key, enemyOx, playerOx)
return false
end
--- Draw clean background wallpaper without battle platforms (e.g. for evolution scene).
function BattleChrome.drawCleanBg(key)
key = key or "building"
local entry = BattleChrome._terrains[key] or BattleChrome._terrains.building
or BattleChrome._terrains.grass
if not entry then return false end
if entry.bgImage and love and love.graphics then
local qBgKey = "terrain_bg_view_" .. key
if not BattleChrome._quads[qBgKey] then
BattleChrome._quads[qBgKey] = love.graphics.newQuad(0, 0, 240, 160, 256, 160)
end
local qBg = BattleChrome._quads[qBgKey]
love.graphics.setColor(1, 1, 1, 1)
if qBg then
love.graphics.draw(entry.bgImage, qBg, 0, 0)
return true
end
end
local qFullKey = "terrain_full_" .. key
if not BattleChrome._quads[qFullKey] and entry.image and love and love.graphics then
BattleChrome._quads[qFullKey] = love.graphics.newQuad(0, 0, 240, 160, entry.w, entry.h)
end
local q = BattleChrome._quads[qFullKey]
if q and entry.image then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(entry.image, q, 0, 0)
return true
end
return false
end
--- Draw textbox panel: message / action / fight.
--- Tilemap chrome starts at y=112 (not 120); panels are 48px tall, 160px apart.
function BattleChrome.drawPanel(mode)
+547
View File
@@ -0,0 +1,547 @@
-- Dedicated Gen 3 Evolution Scene (pret evolution_scene.c & evolution_graphics.c).
-- Handles visual starburst rays, sparkle particles, white silhouette pulse scaling,
-- B-button cancellation, point-of-no-return mutation, audio choreography, and move learning.
local Stack = require("src.ui.game3.stack")
local Display = require("src.core.game3.display")
local FrlgFont = require("src.ui.game3.frlg_font")
local Message = require("src.ui.game3.message")
local BattleChrome = require("src.ui.game3.battle_chrome")
local Pokemon = require("src.core.game3.pokemon")
local Evolution = require("src.core.game3.evolution")
local Audio = require("src.core.game3.audio")
local LearnMove = require("src.core.game3.battle.learn_move")
local SE = require("src.core.game3.se_ids")
local Oam = require("src.core.game3.oam")
local EvolutionScene = {}
EvolutionScene.open = false
EvolutionScene._mon = nil
EvolutionScene._preSpecies = nil
EvolutionScene._postSpecies = nil
EvolutionScene._canStop = true
EvolutionScene._session = nil
EvolutionScene._bag = nil
EvolutionScene._onDone = nil
EvolutionScene._state = "idle"
EvolutionScene._timer = 0
EvolutionScene._frame = 0
EvolutionScene._speed = 8
EvolutionScene._direction = 0
EvolutionScene._preScale = 1.0
EvolutionScene._postScale = 0.0625
EvolutionScene._particles = {}
EvolutionScene._flashAlpha = 0
EvolutionScene._bgAngle = 0
EvolutionScene._bgBrightness = 0
EvolutionScene._savedSong = nil
EvolutionScene._isBattle = false
local silhouetteShader = nil
if love and love.graphics and love.graphics.newShader then
pcall(function()
silhouetteShader = love.graphics.newShader([[
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
vec4 texColor = Texel(texture, texture_coords);
if (texColor.a < 0.05) {
discard;
}
return vec4(color.rgb, texColor.a * color.a);
}
]])
end)
end
local function clean_string(s)
if type(s) ~= "string" then return "" end
return (s:gsub("%z+", ""):match("^%s*(.-)%s*$")) or ""
end
local function is_fanfare_or_evo_song(id)
id = tonumber(id)
if not id or id == 0 or id == 0xFFFF then return true end
if id >= 256 and id <= 271 then return true end
if id == 317 or id == 318 or id == 338 then return true end
return false
end
function EvolutionScene.isOpen()
return EvolutionScene.open
end
--- Start an evolution scene.
-- @param mon table The Pokémon table
-- @param postSpecies number Target species ID
-- @param opts table { canStop, session, bag, isBattle, savedSong, onDone }
function EvolutionScene.start(mon, postSpecies, opts)
opts = opts or {}
if not mon or not postSpecies then
if opts.onDone then opts.onDone("error") end
return false
end
EvolutionScene.open = true
EvolutionScene._mon = mon
EvolutionScene._preSpecies = Pokemon.speciesOf(mon) or 1
EvolutionScene._postSpecies = Pokemon.speciesFromName(postSpecies) or tonumber(postSpecies) or EvolutionScene._preSpecies
EvolutionScene._canStop = opts.canStop ~= false
EvolutionScene._session = opts.session
EvolutionScene._bag = opts.bag
EvolutionScene._onDone = opts.onDone
EvolutionScene._isBattle = opts.isBattle and true or false
EvolutionScene._headless = opts.headless and true or false
local saved = opts.savedSong
if not saved or is_fanfare_or_evo_song(saved) then
if Audio._currentSong and not is_fanfare_or_evo_song(Audio._currentSong.id) then
saved = Audio._currentSong.id
end
end
if not saved or is_fanfare_or_evo_song(saved) then
saved = Audio._mapSong
end
EvolutionScene._savedSong = saved
EvolutionScene._state = "fade_in"
EvolutionScene._timer = 0
EvolutionScene._frame = 0
EvolutionScene._speed = 8
EvolutionScene._direction = 0
EvolutionScene._preScale = 1.0
EvolutionScene._postScale = 0.0625
EvolutionScene._particles = {}
EvolutionScene._flashAlpha = 0
EvolutionScene._bgAngle = 0
EvolutionScene._bgBrightness = 0
-- Ensure OAM is cleared so no party menu/field sprites linger
Oam.destroyAll()
-- Ensure BattleChrome is installed
if not BattleChrome._installed then
BattleChrome.install(nil)
end
-- Ensure pre-evolution and post-evolution pics are cached
Pokemon.frontPic(EvolutionScene._preSpecies)
Pokemon.frontPic(EvolutionScene._postSpecies)
-- Open message box in battle frame
if Message.setFrame then Message.setFrame("battle") end
Stack.push("evolution_scene", EvolutionScene, { hideBelow = true })
return true
end
local function spawn_sparkle(kind)
local cx, cy = 120, 64
local p = {
x = cx,
y = cy,
kind = kind or "spiral",
t = 0,
maxT = math.random(30, 50),
angle = math.random() * math.pi * 2,
dist = math.random(40, 80),
speed = math.random(1, 3),
size = math.random(2, 4),
}
EvolutionScene._particles[#EvolutionScene._particles + 1] = p
end
local function spawn_flash_spray()
local cx, cy = 120, 64
for i = 1, 32 do
local angle = (i / 32) * math.pi * 2 + (math.random() - 0.5) * 0.2
local speed = math.random(2, 5)
EvolutionScene._particles[#EvolutionScene._particles + 1] = {
x = cx,
y = cy,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
kind = "spray",
t = 0,
maxT = math.random(25, 45),
size = math.random(2, 5),
}
end
end
local function finish_scene(result)
EvolutionScene.open = false
Stack.pop("evolution_scene")
local cb = EvolutionScene._onDone
local mon = EvolutionScene._mon
local saved = EvolutionScene._savedSong or Audio._mapSong
EvolutionScene._onDone = nil
EvolutionScene._mon = nil
if Message.close then Message.close() end
-- Restore previous BGM (victory loop if after battle, or map song if from menu/field)
if saved then
if not is_fanfare_or_evo_song(saved) then
Audio.playSong(saved)
elseif Audio._mapSong then
Audio.playSong(Audio._mapSong)
end
end
if cb then cb(result, mon) end
end
local function start_learn_moves()
local mon = EvolutionScene._mon
local postSpecies = EvolutionScene._postSpecies
local level = tonumber(mon.level) or 1
local learnLevels = { level }
local displayName = Pokemon.displayMonName(mon)
local hasMoves = LearnMove.beginQueue(mon, learnLevels, {
displayName = displayName,
headless = EvolutionScene._headless,
pushMsg = function(text, cb)
Message.show(text, { frame = "battle", done = cb })
end,
askYesNo = function(a, b)
local cb = (type(a) == "function") and a or b
local prompt = (type(a) == "string") and a or nil
if prompt then Message.show(prompt, { frame = "battle" }) end
local Choice = require("src.ui.game3.choice")
Choice.yesNo(function(yes)
if cb then cb(yes == true) end
end)
end,
askForget = function(moves_or_prompt, maybe_cb, last_cb)
local cb = (type(maybe_cb) == "function") and maybe_cb or last_cb
local SummaryMenu = require("src.ui.game3.summary_menu")
local LearnMove = require("src.core.game3.battle.learn_move")
SummaryMenu.openMenu({ mon }, 1, {
mode = "select_move",
moveToLearn = LearnMove._moveId,
onSelectMove = function(slotIdx)
if cb then cb(slotIdx) end
end,
})
end,
onDone = function()
finish_scene("evolved")
end,
})
if not hasMoves then
finish_scene("evolved")
end
end
function EvolutionScene.handleInput(input)
if not EvolutionScene.open then return end
local Choice = package.loaded["src.ui.game3.choice"]
if Choice and Choice.active then
if input:wasPressed("up") then
Choice.move(-1)
elseif input:wasPressed("down") then
Choice.move(1)
elseif input:wasPressed("a") then
Choice.confirm()
elseif input:wasPressed("b") then
Choice.cancel()
end
return
end
-- B-Button Cancellation during CYCLE_SPRITES before T_speed >= 128
if EvolutionScene._state == "cycle" and EvolutionScene._canStop and EvolutionScene._speed < 128 then
if input:isDown("b") or input:wasPressed("b") then
EvolutionScene._state = "cancel"
EvolutionScene._timer = 0
Audio.playSong(0)
pcall(function() Audio.playSe(SE.SE_NOT_EFFECTIVE or 2) end)
local fromName = Pokemon.displayMonName(EvolutionScene._mon)
Message.show("Huh? " .. fromName .. "\nstopped evolving!", { frame = "battle" })
return
end
end
-- Advance dialogue on A or B during text states
if EvolutionScene._state == "congrats" or EvolutionScene._state == "cancel" or EvolutionScene._state == "learn_moves" then
if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then
if Message.isOpen and Message.isOpen() then
if Message.isWaiting and Message.isWaiting() then
Message.advance()
else
Message.skipReveal()
end
end
if not (Message.isOpen and Message.isOpen()) then
if EvolutionScene._state == "congrats" then
EvolutionScene._state = "learn_moves"
-- Restore saved music immediately when congratulations message advances (1:1 pret pokefirered)
local saved = EvolutionScene._savedSong or Audio._mapSong
if saved then
if not is_fanfare_or_evo_song(saved) then
Audio.playSong(saved)
elseif Audio._mapSong then
Audio.playSong(Audio._mapSong)
end
end
start_learn_moves()
elseif EvolutionScene._state == "cancel" then
finish_scene("stopped")
end
end
end
end
end
function EvolutionScene.update(dt)
if not EvolutionScene.open then return end
EvolutionScene._frame = EvolutionScene._frame + 1
EvolutionScene._bgAngle = (EvolutionScene._bgAngle + 0.02) % (math.pi * 2)
if Message.isOpen and Message.isOpen() then
Message.tick()
end
if EvolutionScene._state == "learn_moves" then
if LearnMove.busy() then
LearnMove.pump()
end
end
-- Update particles
for i = #EvolutionScene._particles, 1, -1 do
local p = EvolutionScene._particles[i]
p.t = p.t + 1
if p.kind == "spiral" then
p.angle = p.angle + 0.1
p.dist = math.max(0, p.dist - p.speed)
p.x = 120 + math.cos(p.angle) * p.dist
p.y = 64 + math.sin(p.angle) * p.dist
elseif p.kind == "spray" then
p.x = p.x + (p.vx or 0)
p.y = p.y + (p.vy or 0)
end
if p.t >= p.maxT or (p.kind == "spiral" and p.dist <= 2) then
table.remove(EvolutionScene._particles, i)
end
end
local st = EvolutionScene._state
if st == "fade_in" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._timer >= 16 then
EvolutionScene._state = "intro_msg"
EvolutionScene._timer = 0
local fromName = Pokemon.displayMonName(EvolutionScene._mon)
Message.show("What?\n" .. fromName .. " is evolving!", { frame = "battle" })
end
elseif st == "intro_msg" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._timer >= 45 then
EvolutionScene._state = "intro_cry"
EvolutionScene._timer = 0
Audio.playCry(EvolutionScene._preSpecies)
end
elseif st == "intro_cry" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._timer >= 40 then
EvolutionScene._state = "intro_sound"
EvolutionScene._timer = 0
Audio.playSong(263, { loop = false }) -- MUS_EVOLUTION_INTRO
end
elseif st == "intro_sound" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._timer >= 35 then
EvolutionScene._state = "start_music"
EvolutionScene._timer = 0
Audio.playSong(264, { restart = true, loop = true }) -- MUS_EVOLUTION
end
elseif st == "start_music" then
EvolutionScene._timer = EvolutionScene._timer + 1
EvolutionScene._bgBrightness = math.min(1.0, EvolutionScene._timer / 20)
if EvolutionScene._timer >= 20 then
EvolutionScene._state = "cycle"
EvolutionScene._timer = 0
EvolutionScene._speed = 8
EvolutionScene._direction = 0
EvolutionScene._preScale = 1.0
EvolutionScene._postScale = 0.0625
end
elseif st == "cycle" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._frame % 3 == 0 then
spawn_sparkle("spiral")
end
local spd = EvolutionScene._speed / 256
if EvolutionScene._direction == 0 then
-- Pre-evo shrinks, Post-evo grows
EvolutionScene._preScale = math.max(0.0625, EvolutionScene._preScale - spd)
EvolutionScene._postScale = math.min(1.0, EvolutionScene._postScale + spd)
if EvolutionScene._preScale <= 0.0625 and EvolutionScene._postScale >= 1.0 then
EvolutionScene._direction = 1
EvolutionScene._speed = EvolutionScene._speed + 2
end
else
-- Pre-evo grows, Post-evo shrinks
EvolutionScene._preScale = math.min(1.0, EvolutionScene._preScale + spd)
EvolutionScene._postScale = math.max(0.0625, EvolutionScene._postScale - spd)
if EvolutionScene._preScale >= 1.0 and EvolutionScene._postScale <= 0.0625 then
EvolutionScene._direction = 0
EvolutionScene._speed = EvolutionScene._speed + 2
end
end
-- Acceleration cutoff -> Point of no return
if EvolutionScene._speed >= 128 then
EvolutionScene._state = "flash_reveal"
EvolutionScene._timer = 0
EvolutionScene._flashAlpha = 1.0
EvolutionScene._preScale = 0
EvolutionScene._postScale = 1.0
-- POINT OF NO RETURN: Mutate species, stats, nickname, dex, Shedinja now
Evolution.apply(EvolutionScene._mon, EvolutionScene._postSpecies, EvolutionScene._session, EvolutionScene._bag)
pcall(function() Audio.playSe(SE.SE_EXP or 1) end)
spawn_flash_spray()
end
elseif st == "flash_reveal" then
EvolutionScene._timer = EvolutionScene._timer + 1
EvolutionScene._flashAlpha = math.max(0, 1.0 - (EvolutionScene._timer / 15))
if EvolutionScene._timer >= 30 then
EvolutionScene._state = "evo_cry"
EvolutionScene._timer = 0
Audio.playCry(EvolutionScene._postSpecies)
end
elseif st == "evo_cry" then
EvolutionScene._timer = EvolutionScene._timer + 1
if EvolutionScene._timer >= 45 then
EvolutionScene._state = "congrats"
EvolutionScene._timer = 0
Audio.playSong(259, { restart = true, loop = false }) -- MUS_EVOLVED
local fromName = clean_string(EvolutionScene._mon.nickname or Pokemon.name(EvolutionScene._preSpecies))
local intoName = Pokemon.name(EvolutionScene._postSpecies) or "POKéMON"
Message.show("Congratulations! Your " .. fromName .. "\nevolved into " .. intoName .. "!", { frame = "battle" })
end
elseif st == "cancel" then
-- Handled via handleInput
elseif st == "learn_moves" then
-- Handled via LearnMove callbacks
end
end
local function draw_starburst(cx, cy, radius, numRays, angle, color1, color2)
if not (love and love.graphics) then return end
local step = (math.pi * 2) / numRays
for i = 0, numRays - 1 do
local a1 = angle + i * step
local a2 = a1 + step * 0.5
local x1 = cx + math.cos(a1) * radius
local y1 = cy + math.sin(a1) * radius
local x2 = cx + math.cos(a2) * radius
local y2 = cy + math.sin(a2) * radius
love.graphics.setColor(color1)
love.graphics.polygon("fill", cx, cy, x1, y1, x2, y2)
end
end
function EvolutionScene.draw()
if not EvolutionScene.open or not (love and love.graphics) then return end
-- 1. Draw plain battle background (clean wallpaper without platform blotches)
BattleChrome.drawCleanBg("building")
local cx, cy = 120, 64
-- 2. Draw animated starburst background when music active
if EvolutionScene._bgBrightness > 0 then
local b = EvolutionScene._bgBrightness
local c1 = { 0.15 * b, 0.45 * b, 0.85 * b, 0.85 * b }
local c2 = { 0.05 * b, 0.15 * b, 0.40 * b, 0.70 * b }
love.graphics.setColor(c2)
love.graphics.rectangle("fill", 0, 0, Display.W, 112)
draw_starburst(cx, cy, 200, 16, EvolutionScene._bgAngle, c1, c2)
end
-- 3. Draw sparkle particles
for _, p in ipairs(EvolutionScene._particles) do
local alpha = math.min(1.0, (p.maxT - p.t) / 10)
love.graphics.setColor(1, 1, 0.8, alpha)
love.graphics.circle("fill", p.x, p.y, p.size or 2)
end
-- 4. Draw Pokémon sprites (silhouette or full color)
local st = EvolutionScene._state
local prePic = Pokemon.frontPic(EvolutionScene._preSpecies)
local postPic = Pokemon.frontPic(EvolutionScene._postSpecies)
if st == "cycle" then
-- Solid white silhouette shader
if silhouetteShader then
love.graphics.setShader(silhouetteShader)
end
love.graphics.setColor(1, 1, 1, 1)
if prePic and prePic.image and EvolutionScene._preScale > 0.05 then
local s = EvolutionScene._preScale
love.graphics.draw(prePic.image, cx, cy, 0, s, s, 32, 32)
end
if postPic and postPic.image and EvolutionScene._postScale > 0.05 then
local s = EvolutionScene._postScale
love.graphics.draw(postPic.image, cx, cy, 0, s, s, 32, 32)
end
if silhouetteShader then
love.graphics.setShader()
end
elseif st == "cancel" or st == "fade_in" or st == "intro_msg" or st == "intro_cry" or st == "intro_sound" or st == "start_music" then
-- Normal pre-evolution sprite
if prePic and prePic.image then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(prePic.image, cx, cy, 0, 1, 1, 32, 32)
end
else
-- Normal post-evolution sprite (flash_reveal, evo_cry, congrats, learn_moves)
if postPic and postPic.image then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(postPic.image, cx, cy, 0, 1, 1, 32, 32)
end
end
-- 5. Full white flash overlay
if EvolutionScene._flashAlpha > 0 then
love.graphics.setColor(1, 1, 1, EvolutionScene._flashAlpha)
love.graphics.rectangle("fill", 0, 0, Display.W, 112)
end
-- 6. Battle panel chrome & message window
BattleChrome.drawPanel("none")
if Message.isOpen and Message.isOpen() then
Message.draw()
end
local Choice = package.loaded["src.ui.game3.choice"]
if Choice and Choice.active and Choice.draw then
Choice.draw()
end
love.graphics.setColor(1, 1, 1, 1)
end
return EvolutionScene
+404 -49
View File
@@ -10,29 +10,188 @@ FrlgFont.MAX_LETTER_WIDTH = 10
FrlgFont.GLYPH_HEIGHT = 14
FrlgFont.LINE_PITCH = 15 -- maxLetterHeight(14) + lineSpacing(1)
-- Palette indices matching AddTextPrinterDiffStyle / stdpal_0
FrlgFont.COLOR = {
NORMAL = { fg = { 98 / 255, 98 / 255, 98 / 255, 1 },
shadow = { 213 / 255, 213 / 255, 205 / 255, 1 } },
-- MALE / BLUE: GBA scrolling_bg.pal (color 4 #7BBDFF, shadow color 5 #007BFF)
MALE = { fg = { 123 / 255, 189 / 255, 255 / 255, 1 },
shadow = { 0 / 255, 123 / 255, 255 / 255, 1 } },
BLUE = { fg = { 123 / 255, 189 / 255, 255 / 255, 1 },
shadow = { 0 / 255, 123 / 255, 255 / 255, 1 } },
-- FEMALE: GBA scrolling_bg.pal (color 6 #FF8383, shadow color 7 #AC1818)
FEMALE = { fg = { 255 / 255, 131 / 255, 131 / 255, 1 },
shadow = { 172 / 255, 24 / 255, 24 / 255, 1 } },
-- Party slot printers (FONT_SMALL on teal panels).
PARTY = { fg = { 56 / 255, 56 / 255, 56 / 255, 1 },
shadow = { 216 / 255, 216 / 255, 216 / 255, 1 } },
-- Storage text / top-bar (GBA scrolling_bg.pal color 2 #FFFFFF, shadow color 3 #000000).
WHITE = { fg = { 1, 1, 1, 1 },
shadow = { 0, 0, 0, 1 } },
-- Pikachu intro body (sTextColor_DarkGray).
DARK_GRAY = { fg = { 0.35, 0.35, 0.38, 1 },
shadow = { 0.75, 0.75, 0.78, 1 } },
-- 1:1 Standard Text Palettes from pokefirered/graphics/text_window/stdpal_0.pal
-- GBA 15-bit BGR555 -> 8-bit RGB888 / normalized 0.0-1.0
FrlgFont.STDPAL = {
[0] = { 0, 0, 0, 0 }, -- 0: Transparent / Window Fill
[1] = { 255 / 255, 255 / 255, 255 / 255, 1 }, -- 1: WHITE (#FFFFFF)
[2] = { 98 / 255, 98 / 255, 98 / 255, 1 }, -- 2: DARK_GRAY (#626262)
[3] = { 213 / 255, 213 / 255, 205 / 255, 1 }, -- 3: LIGHT_GRAY (#D5D5CD)
[4] = { 230 / 255, 8 / 255, 8 / 255, 1 }, -- 4: RED (#E60808)
[5] = { 255 / 255, 189 / 255, 115 / 255, 1 }, -- 5: LIGHT_RED (#FFBD73)
[6] = { 32 / 255, 156 / 255, 8 / 255, 1 }, -- 6: GREEN (#209C08)
[7] = { 148 / 255, 246 / 255, 148 / 255, 1 }, -- 7: LIGHT_GREEN (#94F694)
[8] = { 49 / 255, 82 / 255, 205 / 255, 1 }, -- 8: BLUE (#3152CD)
[9] = { 164 / 255, 197 / 255, 246 / 255, 1 }, -- 9: LIGHT_BLUE (#A4C5F6)
[10] = { 255 / 255, 255 / 255, 255 / 255, 1 }, -- 10: DYNAMIC_COLOR1
[11] = { 213 / 255, 230 / 255, 246 / 255, 1 }, -- 11: DYNAMIC_COLOR2
[12] = { 164 / 255, 213 / 255, 230 / 255, 1 }, -- 12: DYNAMIC_COLOR3
[13] = { 230 / 255, 246 / 255, 255 / 255, 1 }, -- 13: DYNAMIC_COLOR4
[14] = { 115 / 255, 164 / 255, 197 / 255, 1 }, -- 14: DYNAMIC_COLOR5
[15] = { 74 / 255, 115 / 255, 164 / 255, 1 }, -- 15: DYNAMIC_COLOR6
}
FrlgFont.COLOR_IDS = {
TRANSPARENT = 0,
WHITE = 1,
DARK_GRAY = 2,
LIGHT_GRAY = 3,
RED = 4,
LIGHT_RED = 5,
GREEN = 6,
LIGHT_GREEN = 7,
BLUE = 8,
LIGHT_BLUE = 9,
}
-- 3-Slot Color Architecture (Foreground, Shadow, Background/Highlight)
FrlgFont.COLOR = {
-- Standard NPC / Field Dialogue
NORMAL = { fg = FrlgFont.STDPAL[2], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] },
-- Pokémon Gender Markers (Two tones: Light foreground + Dark shadow)
MALE = { fg = FrlgFont.STDPAL[9], shadow = FrlgFont.STDPAL[8], bg = FrlgFont.STDPAL[0] },
GENDER_MALE = { fg = FrlgFont.STDPAL[9], shadow = FrlgFont.STDPAL[8], bg = FrlgFont.STDPAL[0] },
FEMALE = { fg = FrlgFont.STDPAL[5], shadow = FrlgFont.STDPAL[4], bg = FrlgFont.STDPAL[0] },
GENDER_FEMALE = { fg = FrlgFont.STDPAL[5], shadow = FrlgFont.STDPAL[4], bg = FrlgFont.STDPAL[0] },
-- Party Menu Specific Two-Tone Gender Markers (from gPartyMenuBg_Pal 59/60, 75/76)
PARTY_MALE = {
fg = { 65 / 255, 205 / 255, 255 / 255, 1 },
shadow = { 0 / 255, 98 / 255, 148 / 255, 1 },
bg = FrlgFont.STDPAL[0],
},
PARTY_FEMALE = {
fg = { 255 / 255, 156 / 255, 148 / 255, 1 },
shadow = { 156 / 255, 65 / 255, 57 / 255, 1 },
bg = FrlgFont.STDPAL[0],
},
-- NPC Dialogue Text Colors (Dark Blue / Dark Red fg, Light Gray shadow)
MALE_NPC = { fg = FrlgFont.STDPAL[8], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] },
FEMALE_NPC = { fg = FrlgFont.STDPAL[4], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] },
BLUE = { fg = FrlgFont.STDPAL[8], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] },
RED = { fg = FrlgFont.STDPAL[4], shadow = FrlgFont.STDPAL[5], bg = FrlgFont.STDPAL[0] },
GREEN = { fg = FrlgFont.STDPAL[6], shadow = FrlgFont.STDPAL[7], bg = FrlgFont.STDPAL[0] },
-- Party slot printers & Battle text (White fg, Dark Gray shadow)
WHITE = { fg = FrlgFont.STDPAL[1], shadow = FrlgFont.STDPAL[2], bg = FrlgFont.STDPAL[0] },
PARTY = { fg = FrlgFont.STDPAL[1], shadow = FrlgFont.STDPAL[2], bg = FrlgFont.STDPAL[0] },
STAT = { fg = FrlgFont.STDPAL[4], shadow = FrlgFont.STDPAL[5], bg = FrlgFont.STDPAL[0] },
DARK_GRAY = { fg = FrlgFont.STDPAL[2], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] },
}
-- NPC Text Color Enums matching pokefirered/include/constants/event_objects.h
FrlgFont.NPC_TEXT_COLOR = {
MALE = 0,
FEMALE = 1,
NEUTRAL = 2,
MON = 3,
}
-- 152-element sTextColorTable from pokefirered/src/dynamic_placeholder_text_util.c
-- Each byte holds 2 nybbles: (low_nybble | (high_nybble << 4))
local sTextColorTable = {
[0] = 0x00, -- OBJ_EVENT_GFX_RED_NORMAL / OBJ_EVENT_GFX_RED_BIKE
[1] = 0x00, -- OBJ_EVENT_GFX_RED_SURF / OBJ_EVENT_GFX_RED_FIELD_MOVE
[2] = 0x00, -- OBJ_EVENT_GFX_RED_FISH / OBJ_EVENT_GFX_RED_VS_SEEKER
[3] = 0x10, -- OBJ_EVENT_GFX_RED_VS_SEEKER_BIKE / OBJ_EVENT_GFX_GREEN_NORMAL
[4] = 0x11, -- OBJ_EVENT_GFX_GREEN_BIKE / OBJ_EVENT_GFX_GREEN_SURF
[5] = 0x11, -- OBJ_EVENT_GFX_GREEN_FIELD_MOVE / OBJ_EVENT_GFX_GREEN_FISH
[6] = 0x11, -- OBJ_EVENT_GFX_GREEN_VS_SEEKER / OBJ_EVENT_GFX_GREEN_VS_SEEKER_BIKE
[7] = 0x10, -- OBJ_EVENT_GFX_RS_BRENDAN / OBJ_EVENT_GFX_RS_MAY
[8] = 0x10, -- OBJ_EVENT_GFX_LITTLE_BOY / OBJ_EVENT_GFX_LITTLE_GIRL
[9] = 0x00, -- OBJ_EVENT_GFX_YOUNGSTER / OBJ_EVENT_GFX_BOY
[10] = 0x00, -- OBJ_EVENT_GFX_BUG_CATCHER / OBJ_EVENT_GFX_SITTING_BOY
[11] = 0x11, -- OBJ_EVENT_GFX_LASS / OBJ_EVENT_GFX_WOMAN_1
[12] = 0x01, -- OBJ_EVENT_GFX_CRUSH_GIRL / OBJ_EVENT_GFX_MAN
[13] = 0x00, -- OBJ_EVENT_GFX_ROCKER / OBJ_EVENT_GFX_FAT_MAN
[14] = 0x11, -- OBJ_EVENT_GFX_WOMAN_2 / OBJ_EVENT_GFX_BEAUTY
[15] = 0x10, -- OBJ_EVENT_GFX_BALDING_MAN / OBJ_EVENT_GFX_WOMAN_3
[16] = 0x00, -- OBJ_EVENT_GFX_OLD_MAN_1 / OBJ_EVENT_GFX_OLD_MAN_2
[17] = 0x10, -- OBJ_EVENT_GFX_OLD_MAN_LYING_DOWN / OBJ_EVENT_GFX_OLD_WOMAN
[18] = 0x10, -- OBJ_EVENT_GFX_TUBER_M_WATER / OBJ_EVENT_GFX_TUBER_F
[19] = 0x00, -- OBJ_EVENT_GFX_TUBER_M_LAND / OBJ_EVENT_GFX_CAMPER
[20] = 0x01, -- OBJ_EVENT_GFX_PICNICKER / OBJ_EVENT_GFX_COOLTRAINER_M
[21] = 0x01, -- OBJ_EVENT_GFX_COOLTRAINER_F / OBJ_EVENT_GFX_SWIMMER_M_WATER
[22] = 0x01, -- OBJ_EVENT_GFX_SWIMMER_F_WATER / OBJ_EVENT_GFX_SWIMMER_M_LAND
[23] = 0x01, -- OBJ_EVENT_GFX_SWIMMER_F_LAND / OBJ_EVENT_GFX_WORKER_M
[24] = 0x01, -- OBJ_EVENT_GFX_WORKER_F / OBJ_EVENT_GFX_ROCKET_M
[25] = 0x01, -- OBJ_EVENT_GFX_ROCKET_F / OBJ_EVENT_GFX_GBA_KID
[26] = 0x00, -- OBJ_EVENT_GFX_POKE_MANIAC / OBJ_EVENT_GFX_BIKER
[27] = 0x00, -- OBJ_EVENT_GFX_BLACK_BELT / OBJ_EVENT_GFX_SCIENTIST
[28] = 0x00, -- OBJ_EVENT_GFX_HIKER / OBJ_EVENT_GFX_FISHER
[29] = 0x01, -- OBJ_EVENT_GFX_CHANNELER / OBJ_EVENT_GFX_CHEF
[30] = 0x00, -- OBJ_EVENT_GFX_POLICEMAN / OBJ_EVENT_GFX_GENTLEMAN
[31] = 0x00, -- OBJ_EVENT_GFX_SAILOR / OBJ_EVENT_GFX_CAPTAIN
[32] = 0x11, -- OBJ_EVENT_GFX_NURSE / OBJ_EVENT_GFX_CABLE_CLUB_RECEPTIONIST
[33] = 0x01, -- OBJ_EVENT_GFX_UNION_ROOM_RECEPTIONIST / OBJ_EVENT_GFX_UNUSED_MALE_RECEPTIONIST
[34] = 0x00, -- OBJ_EVENT_GFX_CLERK / OBJ_EVENT_GFX_MG_DELIVERYMAN
[35] = 0x00, -- OBJ_EVENT_GFX_TRAINER_TOWER_DUDE / OBJ_EVENT_GFX_PROF_OAK
[36] = 0x00, -- OBJ_EVENT_GFX_BLUE / OBJ_EVENT_GFX_BILL
[37] = 0x10, -- OBJ_EVENT_GFX_LANCE / OBJ_EVENT_GFX_AGATHA
[38] = 0x11, -- OBJ_EVENT_GFX_DAISY / OBJ_EVENT_GFX_LORELEI
[39] = 0x00, -- OBJ_EVENT_GFX_MR_FUJI / OBJ_EVENT_GFX_BRUNO
[40] = 0x10, -- OBJ_EVENT_GFX_BROCK / OBJ_EVENT_GFX_MISTY
[41] = 0x10, -- OBJ_EVENT_GFX_LT_SURGE / OBJ_EVENT_GFX_ERIKA
[42] = 0x10, -- OBJ_EVENT_GFX_KOGA / OBJ_EVENT_GFX_SABRINA
[43] = 0x00, -- OBJ_EVENT_GFX_BLAINE / OBJ_EVENT_GFX_GIOVANNI
[44] = 0x01, -- OBJ_EVENT_GFX_MOM / OBJ_EVENT_GFX_CELIO
[45] = 0x00, -- OBJ_EVENT_GFX_TEACHY_TV_HOST / OBJ_EVENT_GFX_GYM_GUY
[46] = 0x22, -- OBJ_EVENT_GFX_ITEM_BALL / OBJ_EVENT_GFX_TOWN_MAP
[47] = 0x22, -- OBJ_EVENT_GFX_POKEDEX / OBJ_EVENT_GFX_CUT_TREE
[48] = 0x22, -- OBJ_EVENT_GFX_ROCK_SMASH_ROCK / OBJ_EVENT_GFX_PUSHABLE_BOULDER
[49] = 0x22, -- OBJ_EVENT_GFX_FOSSIL / OBJ_EVENT_GFX_RUBY
[50] = 0x22, -- OBJ_EVENT_GFX_SAPPHIRE / OBJ_EVENT_GFX_OLD_AMBER
[51] = 0x22, -- OBJ_EVENT_GFX_GYM_SIGN / OBJ_EVENT_GFX_SIGN
[52] = 0x22, -- OBJ_EVENT_GFX_TRAINER_TIPS / OBJ_EVENT_GFX_CLIPBOARD
[53] = 0x22, -- OBJ_EVENT_GFX_METEORITE / OBJ_EVENT_GFX_LAPRAS_DOLL
[54] = 0x32, -- OBJ_EVENT_GFX_SEAGALLOP / OBJ_EVENT_GFX_SNORLAX
[55] = 0x33, -- OBJ_EVENT_GFX_SPEAROW / OBJ_EVENT_GFX_CUBONE
[56] = 0x33, -- OBJ_EVENT_GFX_POLIWRATH / OBJ_EVENT_GFX_CLEFAIRY
[57] = 0x33, -- OBJ_EVENT_GFX_PIDGEOT / OBJ_EVENT_GFX_JIGGLYPUFF
[58] = 0x33, -- OBJ_EVENT_GFX_PIDGEY / OBJ_EVENT_GFX_CHANSEY
[59] = 0x33, -- OBJ_EVENT_GFX_OMANYTE / OBJ_EVENT_GFX_KANGASKHAN
[60] = 0x33, -- OBJ_EVENT_GFX_PIKACHU / OBJ_EVENT_GFX_PSYDUCK
[61] = 0x33, -- OBJ_EVENT_GFX_NIDORAN_F / OBJ_EVENT_GFX_NIDORAN_M
[62] = 0x33, -- OBJ_EVENT_GFX_NIDORINO / OBJ_EVENT_GFX_MEOWTH
[63] = 0x33, -- OBJ_EVENT_GFX_SEEL / OBJ_EVENT_GFX_VOLTORB
[64] = 0x33, -- OBJ_EVENT_GFX_SLOWPOKE / OBJ_EVENT_GFX_SLOWBRO
[65] = 0x33, -- OBJ_EVENT_GFX_MACHOP / OBJ_EVENT_GFX_WIGGLYTUFF
[66] = 0x33, -- OBJ_EVENT_GFX_DODUO / OBJ_EVENT_GFX_FEAROW
[67] = 0x33, -- OBJ_EVENT_GFX_MACHOKE / OBJ_EVENT_GFX_LAPRAS
[68] = 0x33, -- OBJ_EVENT_GFX_ZAPDOS / OBJ_EVENT_GFX_MOLTRES
[69] = 0x33, -- OBJ_EVENT_GFX_ARTICUNO / OBJ_EVENT_GFX_MEWTWO
[70] = 0x33, -- OBJ_EVENT_GFX_MEW / OBJ_EVENT_GFX_ENTEI
[71] = 0x33, -- OBJ_EVENT_GFX_SUICUNE / OBJ_EVENT_GFX_RAIKOU
[72] = 0x33, -- OBJ_EVENT_GFX_LUGIA / OBJ_EVENT_GFX_HO_OH
[73] = 0x33, -- OBJ_EVENT_GFX_CELEBI / OBJ_EVENT_GFX_KABUTO
[74] = 0x33, -- OBJ_EVENT_GFX_DEOXYS_D / OBJ_EVENT_GFX_DEOXYS_A
[75] = 0x23, -- OBJ_EVENT_GFX_DEOXYS_N / OBJ_EVENT_GFX_SS_ANNE
}
--- Lookup NPC text color enum from graphicsId (0=Male, 1=Female, 2=Neutral, 3=Mon).
function FrlgFont.getNpcTextColor(graphicId)
if not graphicId then return FrlgFont.NPC_TEXT_COLOR.NEUTRAL end
graphicId = tonumber(graphicId)
if not graphicId or graphicId < 0 then return FrlgFont.NPC_TEXT_COLOR.NEUTRAL end
local idx = math.floor(graphicId / 2)
if idx > 75 or not sTextColorTable[idx] then
return FrlgFont.NPC_TEXT_COLOR.NEUTRAL
end
local shift = (graphicId % 2) * 4
local val = math.floor(sTextColorTable[idx] / (2 ^ shift)) % 16
return val
end
--- Get 3-slot color table for an NPC graphicsId.
function FrlgFont.colorForNpc(graphicId)
local c = FrlgFont.getNpcTextColor(graphicId)
if c == FrlgFont.NPC_TEXT_COLOR.MALE then
return FrlgFont.COLOR.MALE_NPC
elseif c == FrlgFont.NPC_TEXT_COLOR.FEMALE then
return FrlgFont.COLOR.FEMALE_NPC
else
return FrlgFont.COLOR.NORMAL
end
end
-- pret DecompressGlyph_Small: height 13; widths ~4–8 (party nick/HP).
FrlgFont.SMALL_GLYPH_HEIGHT = 13
FrlgFont.SMALL_LINE_PITCH = 14
@@ -253,15 +412,181 @@ local function utf8Chars(s)
end
end
local function resolveColorId(val)
if not val then return nil end
if type(val) == "number" then return FrlgFont.STDPAL[val] end
local upper = tostring(val):upper()
local id = FrlgFont.COLOR_IDS[upper]
if id ~= nil then return FrlgFont.STDPAL[id] end
local num = tonumber(val)
if num ~= nil and FrlgFont.STDPAL[num] then return FrlgFont.STDPAL[num] end
return nil
end
local function copyColors(c)
if not c then
return {
fg = FrlgFont.STDPAL[2],
shadow = FrlgFont.STDPAL[3],
bg = FrlgFont.STDPAL[0],
}
end
return {
fg = c.fg or FrlgFont.STDPAL[2],
shadow = c.shadow or FrlgFont.STDPAL[3],
bg = c.bg or FrlgFont.STDPAL[0],
}
end
--- Byte-by-byte token scanner for GBA FRLG text strings.
-- Handles \xFC bytecode sequences, {TAG} macros, and UTF-8 characters without choking on null bytes.
function FrlgFont.scanTokens(text, initialColors)
local s = tostring(text or "")
local curColors = copyColors(initialColors)
local i, n = 1, #s
return function()
while i <= n do
local b = s:byte(i)
-- 1) 0xFC (EXT_CTRL_CODE)
if b == 0xFC and i + 1 <= n then
local cmd = s:byte(i + 1)
if cmd == 0x01 and i + 2 <= n then -- EXT_CTRL_CODE_COLOR (3 bytes)
local cid = s:byte(i + 2)
curColors.fg = FrlgFont.STDPAL[cid] or curColors.fg
i = i + 3
return "ctrl", "COLOR", curColors
elseif cmd == 0x02 and i + 2 <= n then -- EXT_CTRL_CODE_HIGHLIGHT (3 bytes)
local cid = s:byte(i + 2)
curColors.bg = FrlgFont.STDPAL[cid] or curColors.bg
i = i + 3
return "ctrl", "HIGHLIGHT", curColors
elseif cmd == 0x03 and i + 2 <= n then -- EXT_CTRL_CODE_SHADOW (3 bytes)
local cid = s:byte(i + 2)
curColors.shadow = FrlgFont.STDPAL[cid] or curColors.shadow
i = i + 3
return "ctrl", "SHADOW", curColors
elseif cmd == 0x04 and i + 4 <= n then -- EXT_CTRL_CODE_COLOR_HIGHLIGHT_SHADOW (5 bytes)
local fgId = s:byte(i + 2)
local bgId = s:byte(i + 3)
local shId = s:byte(i + 4)
curColors.fg = FrlgFont.STDPAL[fgId] or curColors.fg
curColors.bg = FrlgFont.STDPAL[bgId] or curColors.bg
curColors.shadow = FrlgFont.STDPAL[shId] or curColors.shadow
i = i + 5
return "ctrl", "COLOR_HIGHLIGHT_SHADOW", curColors
elseif cmd == 0x06 and i + 2 <= n then -- EXT_CTRL_CODE_FONT (3 bytes)
local fontId = s:byte(i + 2)
if fontId == 0x04 then -- FONT_MALE
curColors.fg = FrlgFont.STDPAL[8]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
elseif fontId == 0x05 then -- FONT_FEMALE
curColors.fg = FrlgFont.STDPAL[4]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
elseif fontId == 0x02 then -- FONT_NORMAL
curColors.fg = FrlgFont.STDPAL[2]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
end
i = i + 3
return "ctrl", "FONT", curColors
else
-- Skip variable length commands according to pret text.c
local skip = 2
if cmd == 0x05 or cmd == 0x08 or cmd == 0x0C or cmd == 0x0D
or cmd == 0x0E or cmd == 0x0F or cmd == 0x11 or cmd == 0x12
or cmd == 0x13 or cmd == 0x14 then
skip = 3
elseif cmd == 0x0B or cmd == 0x10 then
skip = 4
end
i = i + skip
return "ctrl", "EXT", curColors
end
-- 2) Braced tag: {TAG}
elseif b == 0x7B then -- '{'
local closePos = s:find("}", i + 1, true)
if closePos then
local tag = s:sub(i + 1, closePos - 1)
local upperTag = tag:upper()
i = closePos + 1
if upperTag == "FONT_MALE" then
curColors.fg = FrlgFont.STDPAL[8]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
return "ctrl", tag, curColors
elseif upperTag == "FONT_FEMALE" then
curColors.fg = FrlgFont.STDPAL[4]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
return "ctrl", tag, curColors
elseif upperTag == "FONT_NORMAL" then
curColors.fg = FrlgFont.STDPAL[2]
curColors.shadow = FrlgFont.STDPAL[3]
curColors.bg = FrlgFont.STDPAL[0]
return "ctrl", tag, curColors
elseif upperTag:sub(1, 6) == "COLOR " then
local val = tag:sub(7):match("^%s*(.-)%s*$")
local col = resolveColorId(val)
if col then curColors.fg = col end
return "ctrl", tag, curColors
elseif upperTag:sub(1, 7) == "SHADOW " then
local val = tag:sub(8):match("^%s*(.-)%s*$")
local col = resolveColorId(val)
if col then curColors.shadow = col end
return "ctrl", tag, curColors
elseif upperTag:sub(1, 10) == "HIGHLIGHT " or upperTag:sub(1, 3) == "BG " then
local val = tag:match("^%S+%s+(.-)%s*$")
local col = resolveColorId(val)
if col then curColors.bg = col end
return "ctrl", tag, curColors
else
-- Non-color placeholder or tag
return "ctrl", tag, curColors
end
else
i = i + 1
return "char", "{", curColors
end
-- 3) Newline
elseif b == 0x0A then -- '\n'
i = i + 1
return "nl", "\n", curColors
elseif b == 0x0C then -- '\f'
i = i + 1
return "page", "\f", curColors
elseif b == 0x0D then -- '\r'
i = i + 1
-- 4) Regular UTF-8 char
else
local len = 1
if b >= 0xF0 then len = 4
elseif b >= 0xE0 then len = 3
elseif b >= 0xC0 then len = 2
end
if i + len - 1 > n then len = 1 end
local ch = s:sub(i, i + len - 1)
i = i + len
return "char", ch, curColors
end
end
return nil
end
end
function FrlgFont.glyphId(ch)
if not ch or ch == "" then return 0x00 end
local rev = buildRev()
local id = rev[ch]
if id then return id end
-- ASCII fallback for unmapped printable
local b = ch:byte(1)
if b and b >= 0x20 and b < 0x7F and #ch == 1 then
-- try upper/lower via CHARMAP reverse already; unknown → space
return 0x00
end
return 0x00
@@ -290,13 +615,13 @@ end
function FrlgFont.measure(text, opts)
opts = opts or {}
local width, line, maxLine = 0, 0, 0
for ch in utf8Chars(tostring(text or "")) do
if ch == "\n" then
local line, maxLine = 0, 0
for ttype, val in FrlgFont.scanTokens(text) do
if ttype == "nl" or ttype == "page" then
if line > maxLine then maxLine = line end
line = 0
else
line = line + FrlgFont.advance(FrlgFont.glyphId(ch), opts)
elseif ttype == "char" then
line = line + FrlgFont.advance(FrlgFont.glyphId(val), opts)
end
end
if line > maxLine then maxLine = line end
@@ -310,7 +635,7 @@ function FrlgFont.wrap(text, maxWidth, opts)
local spaceW = FrlgFont.measure(" ", opts)
local outLines = {}
local rawLines = {}
local clean = tostring(text or ""):gsub("\\n", "\n"):gsub("\\p", "\n")
local clean = tostring(text or ""):gsub("\\n", "\n"):gsub("\\p", "\n"):gsub("\\l", "\n")
for line in (clean .. "\n"):gmatch("(.-)\r?\n") do
rawLines[#rawLines + 1] = line
end
@@ -342,26 +667,41 @@ function FrlgFont.wrap(text, maxWidth, opts)
return table.concat(outLines, "\n")
end
--- Draw full string at pixel (x,y). English: no letterSpacing.
--- Draw full string at pixel (x,y).
-- opts.maxWidth clips (CopyGlyphToWindow). opts.colors = COLOR.NORMAL etc.
-- opts.limitChars: only draw first N UTF-8 characters (typewriter).
-- opts.limitChars: only draw first N printable characters (typewriter).
-- opts.small: use FONT_SMALL (party menu).
function FrlgFont.draw(text, x, y, opts)
opts = opts or {}
-- FONT_SMALL: ROM-baked latin_small_* (hwlat). Fall back to normal if missing.
local useSmall = false
if opts.small then
useSmall = ensure_small() and FrlgFont._small and FrlgFont._small._romBaked
end
if not useSmall and not ensure() then return 0 end
local colors = opts.colors
if not colors then
local baseColors = opts.colors
if not baseColors then
if opts.color then
colors = { fg = opts.color, shadow = opts.shadow or (opts.color == FrlgFont.COLOR.WHITE.fg and FrlgFont.COLOR.WHITE.shadow or FrlgFont.COLOR.NORMAL.shadow) }
baseColors = {
fg = opts.color,
shadow = opts.shadow or (opts.color == FrlgFont.COLOR.WHITE.fg and FrlgFont.COLOR.WHITE.shadow or FrlgFont.COLOR.NORMAL.shadow),
bg = opts.bg or FrlgFont.STDPAL[0],
}
elseif opts.gfxId then
baseColors = FrlgFont.colorForNpc(opts.gfxId)
elseif opts.npcColor then
if opts.npcColor == FrlgFont.NPC_TEXT_COLOR.MALE then
baseColors = FrlgFont.COLOR.MALE
elseif opts.npcColor == FrlgFont.NPC_TEXT_COLOR.FEMALE then
baseColors = FrlgFont.COLOR.FEMALE
else
baseColors = FrlgFont.COLOR.NORMAL
end
else
colors = (useSmall and FrlgFont.COLOR.PARTY) or FrlgFont.COLOR.NORMAL
baseColors = (useSmall and FrlgFont.COLOR.PARTY) or FrlgFont.COLOR.NORMAL
end
end
local maxW = opts.maxWidth or 240
local limit = opts.limitChars
local penX, penY = 0, 0
@@ -383,25 +723,34 @@ function FrlgFont.draw(text, x, y, opts)
end
end
for ch in utf8Chars(tostring(text or "")) do
for ttype, val, curCol in FrlgFont.scanTokens(text, baseColors) do
if limit and drawn >= limit then break end
if ch == "\n" then
if ttype == "nl" then
penX = 0
penY = penY + pitch
drawn = drawn + 1
else
local id = FrlgFont.glyphId(ch)
elseif ttype == "char" then
local id = FrlgFont.glyphId(val)
local adv = FrlgFont.advance(id, useSmall and { small = true } or {})
if penX + adv <= maxW or penX == 0 then
local dx, dy = x + penX, y + penY
local q = quads[id]
if q then
if sh and colors.shadow then
set_col(colors.shadow)
-- Draw background / highlight fill if bg is not transparent
if curCol.bg and curCol.bg[4] and curCol.bg[4] > 0 then
set_col(curCol.bg)
love.graphics.rectangle("fill", dx, dy, adv, pitch)
end
-- Draw shadow
if sh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then
set_col(curCol.shadow)
love.graphics.draw(sh, q, dx, dy)
end
set_col(colors.fg)
love.graphics.draw(fg, q, dx, dy)
-- Draw foreground
if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then
set_col(curCol.fg)
love.graphics.draw(fg, q, dx, dy)
end
end
penX = penX + adv
end
@@ -432,11 +781,15 @@ function FrlgFont.drawGlyph(glyphId, x, y, opts)
end
local q = quads[glyphId]
if not q then return 0 end
if sh and colors.shadow then
if colors.bg and colors.bg[4] and colors.bg[4] > 0 then
love.graphics.setColor(colors.bg)
love.graphics.rectangle("fill", x, y, FrlgFont.advance(glyphId, useSmall and { small = true } or {}), useSmall and FrlgFont.SMALL_LINE_PITCH or FrlgFont.LINE_PITCH)
end
if sh and colors.shadow and (not colors.shadow[4] or colors.shadow[4] > 0) then
love.graphics.setColor(colors.shadow)
love.graphics.draw(sh, q, x, y)
end
if colors.fg then
if colors.fg and (not colors.fg[4] or colors.fg[4] > 0) then
love.graphics.setColor(colors.fg)
else
love.graphics.setColor(1, 1, 1, 1)
@@ -459,11 +812,13 @@ FrlgFont.CHAR_MALE = 0xB5
FrlgFont.CHAR_FEMALE = 0xB6
FrlgFont.CHAR_SLASH = 0xBA
--- Count UTF-8 characters in text (including newlines as 1).
--- Count printable UTF-8 characters in text (including newlines, skipping control codes).
function FrlgFont.countChars(text)
local n = 0
for _ in utf8Chars(tostring(text or "")) do
n = n + 1
for ttype in FrlgFont.scanTokens(text) do
if ttype == "char" or ttype == "nl" then
n = n + 1
end
end
return n
end
+12 -7
View File
@@ -155,15 +155,22 @@ function Hud.update(game, _dt)
local input = game and game.input
if not input then return end
-- Active stack modal menu input takes top precedence
if Stack.busy() then
local inBattle = false
local Battle = package.loaded["src.core.game3.battle"]
if Battle and Battle.isActive and Battle.isActive() then
inBattle = true
end
-- Active stack modal menu input takes top precedence when NOT in battle.
-- When battle is active, Battle.update is the sole dispatcher for battle menus.
if not inBattle and Stack.busy() then
if update_top_menu(input) then
return
end
end
-- Choice first so yes/no works over a stayed message.
if Choice.active then
-- Choice in field/scripting (in battle, Choice is driven by Battle.update).
if not inBattle and Choice.active then
if input:wasPressed("up") then Choice.move(-1)
elseif input:wasPressed("down") then Choice.move(1)
elseif input:wasPressed("a") then Choice.confirm()
@@ -202,9 +209,7 @@ function Hud.update(game, _dt)
return
end
local Battle = package.loaded["src.core.game3.battle"]
if Battle and Battle.isActive and Battle.isActive() then
-- When battle is active, Battle.update is the sole dispatcher for battle menus.
if inBattle then
return
end
+21 -1
View File
@@ -78,6 +78,26 @@ function Message.show(text, opts)
-- (battle Ui draws its own textbox; field msgs need Chrome.dialogueFrame).
Message._frame = "dialogue"
end
-- Resolve default ambient text colors
if opts.colors then
Message._colors = opts.colors
elseif opts.gfxId then
Message._colors = FrlgFont.colorForNpc(opts.gfxId)
elseif opts.npcColor ~= nil then
if opts.npcColor == FrlgFont.NPC_TEXT_COLOR.MALE then
Message._colors = FrlgFont.COLOR.MALE_NPC
elseif opts.npcColor == FrlgFont.NPC_TEXT_COLOR.FEMALE then
Message._colors = FrlgFont.COLOR.FEMALE_NPC
else
Message._colors = FrlgFont.COLOR.NORMAL
end
elseif Message._frame == "battle" then
Message._colors = FrlgFont.COLOR.WHITE
else
Message._colors = FrlgFont.COLOR.NORMAL
end
-- Prefer session options text speed when not overridden.
local speed = opts.speed
if speed == nil then
@@ -246,7 +266,7 @@ function Message.drawText()
local drawn, endX, endY = FrlgFont.draw(page, baseX, baseY, {
maxWidth = maxW,
limitChars = Message._revealed,
colors = (Message._frame == "battle") and FrlgFont.COLOR.WHITE or FrlgFont.COLOR.NORMAL,
colors = (Message._frame == "battle") and FrlgFont.COLOR.WHITE or (Message._colors or FrlgFont.COLOR.NORMAL),
})
if Message._waiting and not Message._stay then
+122 -18
View File
@@ -3,6 +3,7 @@
local Display = require("src.core.game3.display")
local Extract = require("src.import.gba.extract_island1")
local PartyChromeExtract = require("src.import.gba.party_chrome_extract")
local FrlgFont = require("src.ui.game3.frlg_font")
local PartyChrome = {}
@@ -12,6 +13,10 @@ PartyChrome._balls = nil
PartyChrome._slotMain = nil
PartyChrome._slotWide = nil
PartyChrome._slotEmpty = nil
PartyChrome._cancelBtn = nil
PartyChrome._cancelBtnSel = nil
PartyChrome._confirmBtn = nil
PartyChrome._confirmBtnSel = nil
PartyChrome._status = nil
PartyChrome._manifest = nil
PartyChrome._logged = false
@@ -119,7 +124,13 @@ function PartyChrome.install(cache)
PartyChrome._balls = nil
PartyChrome._slotMain = nil
PartyChrome._slotWide = nil
PartyChrome._slotMainSel = nil
PartyChrome._slotWideSel = nil
PartyChrome._slotEmpty = nil
PartyChrome._cancelBtn = nil
PartyChrome._cancelBtnSel = nil
PartyChrome._confirmBtn = nil
PartyChrome._confirmBtnSel = nil
PartyChrome._status = nil
PartyChrome._manifest = load_lua(party_root() .. "/manifest.lua")
PartyChrome._logged = false
@@ -137,6 +148,52 @@ local function man()
return PartyChrome._manifest or {}
end
local function ensureCancelButton(selected)
if selected then
if PartyChrome._cancelBtnSel then return PartyChrome._cancelBtnSel end
local m = man()
local w, h = m.cancelButtonW or 56, m.cancelButtonH or 16
local img = rgba_to_image(read_bytes(party_root() .. "/cancel_button_selected.rgba"), w, h)
if img then
PartyChrome._cancelBtnSel = { image = img, w = w, h = h }
return PartyChrome._cancelBtnSel
end
else
if PartyChrome._cancelBtn then return PartyChrome._cancelBtn end
local m = man()
local w, h = m.cancelButtonW or 56, m.cancelButtonH or 16
local img = rgba_to_image(read_bytes(party_root() .. "/cancel_button.rgba"), w, h)
if img then
PartyChrome._cancelBtn = { image = img, w = w, h = h }
return PartyChrome._cancelBtn
end
end
return nil
end
local function ensureConfirmButton(selected)
if selected then
if PartyChrome._confirmBtnSel then return PartyChrome._confirmBtnSel end
local m = man()
local w, h = m.cancelButtonW or 56, m.cancelButtonH or 16
local img = rgba_to_image(read_bytes(party_root() .. "/confirm_button_selected.rgba"), w, h)
if img then
PartyChrome._confirmBtnSel = { image = img, w = w, h = h }
return PartyChrome._confirmBtnSel
end
else
if PartyChrome._confirmBtn then return PartyChrome._confirmBtn end
local m = man()
local w, h = m.cancelButtonW or 56, m.cancelButtonH or 16
local img = rgba_to_image(read_bytes(party_root() .. "/confirm_button.rgba"), w, h)
if img then
PartyChrome._confirmBtn = { image = img, w = w, h = h }
return PartyChrome._confirmBtn
end
end
return nil
end
local function ensureBg()
if PartyChrome._bg then return PartyChrome._bg end
local m = man()
@@ -151,25 +208,38 @@ local function ensureBg()
return PartyChrome._bg
end
local function ensureSlot(kind)
if kind == "main" and PartyChrome._slotMain then return PartyChrome._slotMain end
if kind == "wide" and PartyChrome._slotWide then return PartyChrome._slotWide end
if kind == "empty" and PartyChrome._slotEmpty then return PartyChrome._slotEmpty end
local function ensureSlot(kind, selected)
if selected then
if kind == "main" and PartyChrome._slotMainSel then return PartyChrome._slotMainSel end
if kind == "wide" and PartyChrome._slotWideSel then return PartyChrome._slotWideSel end
else
if kind == "main" and PartyChrome._slotMain then return PartyChrome._slotMain end
if kind == "wide" and PartyChrome._slotWide then return PartyChrome._slotWide end
if kind == "empty" and PartyChrome._slotEmpty then return PartyChrome._slotEmpty end
end
local m = man()
local file, w, h
if kind == "main" then
file, w, h = "slot_main.rgba", m.slotMainW or 80, m.slotMainH or 56
file = selected and "slot_main_selected.rgba" or "slot_main.rgba"
w, h = m.slotMainW or 80, m.slotMainH or 56
elseif kind == "empty" then
file, w, h = "slot_wide_empty.rgba", m.slotWideW or 144, m.slotWideH or 24
file = "slot_wide_empty.rgba"
w, h = m.slotWideW or 144, m.slotWideH or 24
else
file, w, h = "slot_wide.rgba", m.slotWideW or 144, m.slotWideH or 24
file = selected and "slot_wide_selected.rgba" or "slot_wide.rgba"
w, h = m.slotWideW or 144, m.slotWideH or 24
end
local img = rgba_to_image(read_bytes(party_root() .. "/" .. file), w, h)
if not img then return nil end
local entry = { image = img, w = w, h = h }
if kind == "main" then PartyChrome._slotMain = entry
elseif kind == "empty" then PartyChrome._slotEmpty = entry
else PartyChrome._slotWide = entry end
if selected then
if kind == "main" then PartyChrome._slotMainSel = entry
else PartyChrome._slotWideSel = entry end
else
if kind == "main" then PartyChrome._slotMain = entry
elseif kind == "empty" then PartyChrome._slotEmpty = entry
else PartyChrome._slotWide = entry end
end
return entry
end
@@ -226,23 +296,27 @@ end
--- Draw pret slot panel at window tile coords. kind: main|wide|empty
function PartyChrome.drawSlot(kind, tileLeft, tileTop, selected)
local slot = ensureSlot(kind == "main" and "main" or (kind == "empty" and "empty" or "wide"))
local slot = ensureSlot(kind == "main" and "main" or (kind == "empty" and "empty" or "wide"), selected)
local T = Display.TILE or 8
local px, py = tileLeft * T, tileTop * T
love.graphics.setColor(1, 1, 1, 1)
if slot and slot.image then
love.graphics.draw(slot.image, px, py)
if selected then
love.graphics.setColor(1, 1, 0.7, 0.18)
love.graphics.rectangle("fill", px, py, slot.w, slot.h)
love.graphics.setColor(1, 1, 1, 1)
end
return
end
love.graphics.setColor(selected and 0.55 or 0.40, selected and 0.82 or 0.72, 0.88, 1)
local pw = (kind == "main") and 80 or 144
local ph = (kind == "main") and 56 or 24
love.graphics.rectangle("fill", px, py, pw, ph)
if selected then
love.graphics.setColor(0.48, 0.84, 0.94, 1)
love.graphics.rectangle("fill", px, py, pw, ph)
love.graphics.setColor(1.0, 0.45, 0.19, 1)
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", px + 1, py + 1, pw - 2, ph - 2)
love.graphics.setLineWidth(1)
else
love.graphics.setColor(0.40, 0.72, 0.88, 1)
love.graphics.rectangle("fill", px, py, pw, ph)
end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -298,4 +372,34 @@ function PartyChrome.statusFrameFor(status)
return 1
end
function PartyChrome.drawCancelButton(px, py, selected)
px = px or 184
py = py or 136
local btn = ensureCancelButton(selected)
love.graphics.setColor(1, 1, 1, 1)
if btn and btn.image then
love.graphics.draw(btn.image, px, py)
end
PartyChrome.drawBall(px - 2, py - 4, selected and 1 or 0)
FrlgFont.draw("CANCEL", px + 20, py + 1, {
colors = FrlgFont.COLOR.PARTY,
small = true,
})
end
function PartyChrome.drawConfirmButton(px, py, selected)
px = px or 184
py = py or 128
local btn = ensureConfirmButton(selected)
love.graphics.setColor(1, 1, 1, 1)
if btn and btn.image then
love.graphics.draw(btn.image, px, py)
end
PartyChrome.drawBall(px - 2, py - 4, selected and 1 or 0)
FrlgFont.draw("OK", px + 25, py + 2, {
colors = FrlgFont.COLOR.PARTY,
small = true,
})
end
return PartyChrome
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -442,21 +442,26 @@ function SummaryChrome.drawTypeBadge(typeId, x, y)
end
--- Draw Move Selection Cursor (Red for selecting, Blue for swap target)
--- 1:1 pret PokeSum_CreateMoveSelectionCursorObjs (two 64x32 sprites at x, x+64)
function SummaryChrome.drawMoveSelectionCursor(x, y, w, h, isBlue)
if not (love and love.graphics) then return end
if type(w) == "boolean" then
isBlue = w
w, h = 128, 32
end
local curL, curR = SummaryChrome.cursorImages()
if curL and curR then
-- Frame 0 is Red (selecting), Frame 1 is Blue (swapping)
-- Frame 0 is Red (selecting: 0..31), Frame 1 is Blue (swapping: 32..63)
local vOffset = isBlue and 32 or 0
local qL = get_quad("cur_l_" .. (isBlue and "b" or "r"), 0, vOffset, 16, 24, 64, 64)
local qR = get_quad("cur_r_" .. (isBlue and "b" or "r"), 0, vOffset, 16, 24, 64, 64)
local qL = get_quad("cur_l_" .. (isBlue and "b" or "r"), 0, vOffset, 64, 32, 64, 64)
local qR = get_quad("cur_r_" .. (isBlue and "b" or "r"), 0, vOffset, 64, 32, 64, 64)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(curL, qL, x - 4, y - 2)
love.graphics.draw(curR, qR, x + w - 12, y - 2)
love.graphics.draw(curL, qL, x, y)
love.graphics.draw(curR, qR, x + 64, y)
else
-- Fallback outline
love.graphics.setColor(isBlue and { 0.2, 0.4, 0.9, 1 } or { 0.9, 0.2, 0.2, 1 })
love.graphics.rectangle("line", x, y, w, h)
love.graphics.rectangle("line", x, y, w or 128, h or 32)
end
end
+146 -39
View File
@@ -89,6 +89,29 @@ local function moves_for_mon(mon)
}
end
end
if SummaryMenu._mode == "select_move" and SummaryMenu._moveToLearn then
local newId = tonumber(SummaryMenu._moveToLearn) or SummaryMenu._moveToLearn
local mdef = Pokemon.battleMove(newId)
local name = Pokemon.moveName(newId)
if not name or name == "" or name:match("^MOVE ") then
name = (mdef and mdef.name) or name or ("MOVE " .. tostring(newId))
end
local maxPp = (mdef and mdef.pp) or 5
local mType = (mdef and (mdef.type or mdef.kind)) or "NORMAL"
local power = (mdef and mdef.power and mdef.power > 0) and tostring(mdef.power) or "---"
local acc = (mdef and mdef.accuracy and mdef.accuracy > 0) and tostring(mdef.accuracy) or "---"
out[5] = {
id = newId,
name = name,
pp = maxPp,
maxPp = maxPp,
type = mType,
power = power,
accuracy = acc,
}
end
return out
end
@@ -104,6 +127,9 @@ function SummaryMenu.openMenu(party, startIndex, opts)
SummaryMenu._playerState = opts.playerState or opts.session
SummaryMenu._context = opts.context or "party"
SummaryMenu._onClose = opts.onClose
SummaryMenu._mode = opts.mode -- "select_move" | "party" | nil
SummaryMenu._moveToLearn = opts.moveToLearn or opts.moveId
SummaryMenu._onSelectMove = opts.onSelectMove
SummaryMenu._moveCursor = 1
SummaryMenu._swapSlot = nil
SummaryMenu._slide.active = false
@@ -111,6 +137,8 @@ function SummaryMenu.openMenu(party, startIndex, opts)
local mon = current_mon()
if mon and mon.isEgg then
SummaryMenu._page = PAGE_EGG
elseif SummaryMenu._mode == "select_move" then
SummaryMenu._page = PAGE_MOVES_INFO
else
SummaryMenu._page = tonumber(opts.page) or PAGE_INFO
end
@@ -123,12 +151,19 @@ function SummaryMenu.close()
SummaryMenu.open = false
SummaryMenu._slide.active = false
SummaryMenu._swapSlot = nil
SummaryMenu._mode = nil
SummaryMenu._moveToLearn = nil
local selectCb = SummaryMenu._onSelectMove
SummaryMenu._onSelectMove = nil
Stack.pop("summary")
if SummaryMenu._onClose then
local cb = SummaryMenu._onClose
SummaryMenu._onClose = nil
cb()
end
if selectCb then
selectCb(nil)
end
end
local function change_mon(delta)
@@ -185,6 +220,50 @@ function SummaryMenu.handleInput(input)
return
end
-- Select move mode for move replacement (1:1 pret ShowSelectMovePokemonSummaryScreen)
if SummaryMenu._mode == "select_move" then
local moves = moves_for_mon(mon)
local nMoves = #moves
if nMoves < 5 then nMoves = 5 end
if input:wasPressed("up") then
SummaryMenu._moveCursor = ((SummaryMenu._moveCursor - 2) % nMoves) + 1
pcall(function() require("src.core.game3.audio").playSe(5) end)
elseif input:wasPressed("down") then
SummaryMenu._moveCursor = (SummaryMenu._moveCursor % nMoves) + 1
pcall(function() require("src.core.game3.audio").playSe(5) end)
elseif input:wasPressed("a") then
if SummaryMenu._moveCursor <= 4 then
local chosenMove = moves[SummaryMenu._moveCursor]
local moveId = chosenMove and chosenMove.id
if moveId and Pokemon.isHmMove(moveId) then
pcall(function() require("src.core.game3.audio").playSe(9) end)
else
pcall(function() require("src.core.game3.audio").playSe(5) end)
local slotIdx = SummaryMenu._moveCursor - 1 -- 0-indexed (0..3)
local cb = SummaryMenu._onSelectMove
SummaryMenu._onSelectMove = nil
SummaryMenu.close()
if cb then cb(slotIdx) end
end
else
-- Selected 5th slot (the move to learn / cancel)
pcall(function() require("src.core.game3.audio").playSe(5) end)
local cb = SummaryMenu._onSelectMove
SummaryMenu._onSelectMove = nil
SummaryMenu.close()
if cb then cb(nil) end
end
elseif input:wasPressed("b") then
pcall(function() require("src.core.game3.audio").playSe(9) end)
local cb = SummaryMenu._onSelectMove
SummaryMenu._onSelectMove = nil
SummaryMenu.close()
if cb then cb(nil) end
end
return
end
-- Egg page navigation
if SummaryMenu._page == PAGE_EGG then
if input:wasPressed("up") then
@@ -304,52 +383,77 @@ end
local function draw_header(mon)
local c = coords()
local species = Pokemon.speciesOf(mon)
local isMovesPage = (SummaryMenu._page == PAGE_MOVES or SummaryMenu._page == PAGE_MOVES_INFO)
-- Nickname + level + gender live in the left LVL_NICK strip (not the right pane).
local nick = Pokemon.displayName(mon)
local nx, ny = cxy("name", 40, 18)
draw_text(nick, nx, ny, 64, "NORMAL")
local lv = tonumber(mon.level) or 1
local lx, ly = cxy("level", 4, 18)
draw_text(string.format("Lv%d", lv), lx, ly, 36, "NORMAL")
-- In pret pokefirered (pokemon_summary_screen.c:2430), Level is NOT printed on PAGE_MOVES_INFO
if SummaryMenu._page ~= PAGE_MOVES_INFO then
local lv = tonumber(mon.level) or 1
local lx, ly = cxy("level", 4, 18)
draw_text(string.format("Lv%d", lv), lx, ly, 36, "NORMAL")
end
local gender = SummaryData.gender(mon)
local gx, gy = cxy("gender", 105, 18)
if gender == "M" then
FrlgFont.draw("♂", gx, gy, { colors = FrlgFont.COLOR.MALE, small = true })
FrlgFont.draw("♂", gx, gy, { colors = FrlgFont.COLOR.MALE, small = false })
elseif gender == "F" then
FrlgFont.draw("♀", gx, gy, { colors = FrlgFont.COLOR.FEMALE, small = true })
FrlgFont.draw("♀", gx, gy, { colors = FrlgFont.COLOR.FEMALE, small = false })
end
if SummaryData.isShiny(mon) then
local sx, sy = cxy("shinyStar", 8, 40)
local sx, sy = isMovesPage and 8 or 8, isMovesPage and 24 or 40
SummaryChrome.drawShinyStar(sx, sy)
end
local ailment = SummaryData.statusAilment(mon)
if ailment > 0 then
local key = (SummaryMenu._page == PAGE_MOVES_INFO) and "statusMovesInfo" or "status"
local ax, ay = cxy(key, 16, 38)
local ax, ay = isMovesPage and 16 or 16, isMovesPage and 44 or 38
SummaryChrome.drawStatusIcon(ax, ay, ailment)
end
-- pret CreateMonPicSprite(..., 60, 65): CreateSprite center of 64×64 (see mon_pic.lua).
local pic = c.monPic or { x = 60, y = 65 }
local cx, cy = pic.x or 60, pic.y or 65
local front = Pokemon.frontPic(species)
if front and front.image and love and love.graphics then
local iw = front.w or 64
local ih = front.h or 64
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(front.image, cx - iw / 2, cy - ih / 2)
else
-- In pret pokefirered (pokemon_summary_screen.c:1635, 1681, 1979-1984, 4139-4175):
-- On PAGE_MOVES (Known Moves) and PAGE_MOVES_INFO (Move Details), the large 64x64 front pic is HIDDEN.
-- Instead, the 32x32 party mon icon is displayed below the level/name plate at (24, 34).
if isMovesPage then
local icon = Pokemon.icon(species)
if icon and icon.image and love and love.graphics then
local iw = icon.w or 32
local ih = icon.h or 32
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(icon.image, cx - iw / 2, cy - ih / 2)
local q = icon.quads and icon.quads[0]
if q then
love.graphics.draw(icon.image, q, 24 - iw / 2, 34 - ih / 2)
else
love.graphics.draw(icon.image, 24 - iw / 2, 34 - ih / 2)
end
end
else
local pic = c.monPic or { x = 60, y = 65 }
local cx, cy = pic.x or 60, pic.y or 65
local front = Pokemon.frontPic(species)
if front and front.image and love and love.graphics then
local iw = front.w or 64
local ih = front.h or 64
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(front.image, cx - iw / 2, cy - ih / 2)
else
local icon = Pokemon.icon(species)
if icon and icon.image and love and love.graphics then
local iw = icon.w or 32
local ih = icon.h or 32
love.graphics.setColor(1, 1, 1, 1)
local q = icon.quads and icon.quads[0]
if q then
love.graphics.draw(icon.image, q, cx - iw / 2, cy - ih / 2)
else
love.graphics.draw(icon.image, cx - iw / 2, cy - ih / 2)
end
end
end
end
end
@@ -459,8 +563,9 @@ end
local function draw_page_moves(mon, isDetail)
local moves = moves_for_mon(mon)
local slots = move_slots()
local maxSlot = (SummaryMenu._mode == "select_move") and 5 or 4
for i = 1, 4 do
for i = 1, maxSlot do
local slot = slots[i] or {
nameX = 163, nameY = 21 + (i - 1) * 28,
typeX = 123, typeY = 21 + (i - 1) * 28,
@@ -478,15 +583,11 @@ local function draw_page_moves(mon, isDetail)
end
if isDetail then
local cur = slots[SummaryMenu._moveCursor] or slots[1]
if cur then
SummaryChrome.drawMoveSelectionCursor(120, cur.nameY - 5, 114, 26, false)
end
local curY = 18 + (SummaryMenu._moveCursor - 1) * 28
SummaryChrome.drawMoveSelectionCursor(120, curY, false)
if SummaryMenu._swapSlot then
local swap = slots[SummaryMenu._swapSlot]
if swap then
SummaryChrome.drawMoveSelectionCursor(120, swap.nameY - 5, 114, 26, true)
end
local swapY = 18 + (SummaryMenu._swapSlot - 1) * 28
SummaryChrome.drawMoveSelectionCursor(120, swapY, true)
end
local selMove = moves[SummaryMenu._moveCursor]
@@ -537,13 +638,22 @@ local PAGE_TITLES = {
}
local function get_controls_str(page, isEgg)
if isEgg then return "CANCEL" end
if page == PAGE_INFO then return "CANCEL PAGE"
elseif page == PAGE_SKILLS then return "PAGE"
elseif page == PAGE_MOVES then return "PAGE DETAIL"
elseif page == PAGE_MOVES_INFO then return "SWITCH CANCEL"
if SummaryMenu._mode == "select_move" then
return "{DPAD_UPDOWN}PICK"
end
return "PAGE"
if isEgg then
return "{A_BUTTON}CANCEL"
end
if page == PAGE_INFO then
return "{DPAD_RIGHT}PAGE {A_BUTTON}CANCEL"
elseif page == PAGE_SKILLS then
return "{DPAD_LEFTRIGHT}PAGE"
elseif page == PAGE_MOVES then
return "{DPAD_LEFT}PAGE {A_BUTTON}DETAIL"
elseif page == PAGE_MOVES_INFO then
return "{DPAD_UPDOWN}PICK {A_BUTTON}SWITCH"
end
return "{DPAD_LEFTRIGHT}PAGE"
end
local function draw_top_bar_text(page, isEgg)
@@ -554,11 +664,8 @@ local function draw_top_bar_text(page, isEgg)
})
local ctrl = get_controls_str(page, isEgg)
local w = FrlgFont.measure(ctrl, { small = true })
FrlgFont.draw(ctrl, 236 - w, 1, {
colors = FrlgFont.COLOR.WHITE,
small = true,
})
local PokedexChrome = require("src.ui.game3.pokedex_chrome")
PokedexChrome.drawControlInfo(ctrl, 236, 1)
end
function SummaryMenu.draw()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env luajit
-- Test for ROM-native battle animation pack and tag sprites.
-- Run: luajit tests/game3_battle_anim_pack_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
print("=== 1. Load Extracted Battle Anim Pack ===")
local packPath = (os.getenv("HOME") or ".") .. "/.local/share/love/pokemon-love2d/firered/data/generated/gba/pokemon/battle_anims/pack.lua"
local chunk, err = loadfile(packPath)
check(chunk ~= nil, "pack.lua loads without syntax errors: " .. tostring(err))
local pack = chunk and chunk() or {}
check(type(pack) == "table", "pack is a table")
check(pack.version == 3, "pack version is 3 (ROM-native)")
check(type(pack.moves) == "table", "pack.moves is a table")
check(type(pack.labels) == "table", "pack.labels is a table")
check(type(pack.tags) == "table", "pack.tags is a table")
print("\n=== 2. Verify Move Scripts ===")
local totalMoves = 0
local totalOps = 0
local opCounts = {}
for id = 0, 354 do
local script = pack.moves[id]
if script and #script > 0 then
totalMoves = totalMoves + 1
totalOps = totalOps + #script
for _, op in ipairs(script) do
local code = op.op or op[1]
opCounts[code] = (opCounts[code] or 0) + 1
end
end
end
check(totalMoves == 355, string.format("All 355 moves decoded (found %d)", totalMoves))
check(totalOps > 1000, string.format("Decoded %d total IR ops across all moves", totalOps))
print("Opcode distribution across moves:")
for code, count in pairs(opCounts) do
print(string.format(" %-20s: %d", code, count))
end
print("\n=== 3. Verify Specific Move Bytecode Details ===")
-- MOVE_TACKLE = 33
local tackle = pack.moves[33]
check(tackle ~= nil and #tackle > 0, "MOVE_TACKLE (33) exists")
local hasSprite = false
local hasLunge = false
for _, op in ipairs(tackle) do
if op.op == "createsprite" then
hasSprite = true
if op.noGfx then hasLunge = true end
end
end
check(hasSprite, "Tackle has createsprite op")
check(hasLunge, "Tackle has noGfx (HorizontalLunge) sprite op")
-- MOVE_SLASH = 163
local slash = pack.moves[163]
check(slash ~= nil and #slash > 0, "MOVE_SLASH (163) exists")
local hasSlashTag = false
for _, op in ipairs(slash or {}) do
if op.op == "createsprite" and op.tag == "SLASH" then
hasSlashTag = true
end
end
check(hasSlashTag, "Slash has createsprite with tag=SLASH")
print("\n=== 4. Verify Extracted Tag Sprites ===")
local tagCount = 0
for name, meta in pairs(pack.tags) do
tagCount = tagCount + 1
assert(meta.file, "tag " .. name .. " has file field")
assert(meta.w and meta.w > 0, "tag " .. name .. " has positive w")
assert(meta.h and meta.h > 0, "tag " .. name .. " has positive h")
end
check(tagCount >= 180, string.format("Extracted %d tag sprite metadata entries", tagCount))
local slashMeta = pack.tags["SLASH"]
check(slashMeta ~= nil, "SLASH tag metadata exists")
if slashMeta then
check(slashMeta.w == 32, string.format("SLASH width is 32 (got %s)", tostring(slashMeta.w)))
check(slashMeta.h == 128, string.format("SLASH height is 128 (got %s)", tostring(slashMeta.h)))
end
local impactMeta = pack.tags["IMPACT"]
check(impactMeta ~= nil, "IMPACT tag metadata exists")
if impactMeta then
check(impactMeta.w == 32, string.format("IMPACT width is 32 (got %s)", tostring(impactMeta.w)))
end
if failed > 0 then
print(string.format("\n[FAIL] %d tests failed!", failed))
os.exit(1)
else
print("\n[ok] All battle anim pack tests passed successfully!")
end
+80
View File
@@ -0,0 +1,80 @@
local AnimSprites = require("src.core.game3.battle.anim_sprites")
local AnimCallbacks = require("src.core.game3.battle.anim_callbacks")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
print("=== 1. Testing Slot Z Calculation ===")
assert(AnimSprites.slotZ("enemy", "behind") == 90, "Enemy behind Z should be 90")
assert(AnimSprites.slotZ("enemy", "mon") == 100, "Enemy mon Z should be 100")
assert(AnimSprites.slotZ("enemy", "front") == 110, "Enemy front Z should be 110")
assert(AnimSprites.slotZ("player", "behind") == 190, "Player behind Z should be 190")
assert(AnimSprites.slotZ("player", "mon") == 200, "Player mon Z should be 200")
assert(AnimSprites.slotZ("player", "front") == 210, "Player front Z should be 210")
print("[ok] Slot Z calculation verified for singles and doubles!")
print("=== 2. Testing Affine Orbit & Vortex Callbacks ===")
AnimSprites.reset()
local s1 = AnimSprites.acquire({
x = 100, y = 100,
callback = AnimCallbacks.ParticleInVortex,
data = { [1] = 0 }
})
assert(s1, "Failed to acquire sprite for vortex")
assert(s1.scaleX == 1 and s1.scaleY == 1, "Initial scale should be 1")
-- Step 5 frames
for _ = 1, 5 do
AnimSprites.update()
end
assert(s1.rotation > 0, "Vortex sprite rotation should advance")
assert(s1.ox ~= 0 or s1.oy ~= 0, "Vortex sprite translation should advance")
print(string.format("[ok] ParticleInVortex stepped: rot=%.3f, ox=%d, oy=%d", s1.rotation, s1.ox, s1.oy))
print("=== 3. Testing DragonDanceOrb Pulsing Affine Scale ===")
AnimSprites.reset()
local s2 = AnimSprites.acquire({
x = 50, y = 50,
callback = AnimCallbacks.DragonDanceOrb
})
for _ = 1, 6 do
AnimSprites.update()
end
assert(s2.scaleX ~= 1.0, "DragonDanceOrb scaleX should pulse")
assert(s2.rotation > 0, "DragonDanceOrb rotation should advance")
print(string.format("[ok] DragonDanceOrb stepped: scaleX=%.3f, rot=%.3f", s2.scaleX, s2.rotation))
print("=== 4. Testing Additive Energy Shield (DefensiveWall) ===")
AnimSprites.reset()
local s3 = AnimSprites.acquire({
x = 80, y = 80,
callback = AnimCallbacks.DefensiveWall,
z = AnimSprites.slotZ("player", "front")
})
AnimSprites.update()
assert(s3.blendMode == "add", "DefensiveWall should have blendMode='add'")
assert(s3.z == 210, "DefensiveWall should be at PLAYER_FRONT (Z=210)")
print("[ok] DefensiveWall blendMode and Z-depth verified!")
print("=== 5. Testing Host Lifecycle Sweep ===")
assert(s3.active == true, "s3 should be active")
s3.hostId = "player"
AnimSprites.clearHost("player")
assert(s3.active == false, "s3 should be cleared after host player faints/switches")
print("[ok] Host lifecycle sweep verified!")
print("=== 6. Testing Sorted Draw List Range Filtering ===")
AnimSprites.reset()
local spBehind = AnimSprites.acquire({ z = 50 })
local spMid = AnimSprites.acquire({ z = 150 })
local spFront = AnimSprites.acquire({ z = 250 })
local listBehind = AnimSprites.sortedDrawList({}, 0, 99)
assert(#listBehind == 1 and listBehind[1] == spBehind, "Draw list behind count mismatch")
local listMid = AnimSprites.sortedDrawList({}, 101, 199)
assert(#listMid == 1 and listMid[1] == spMid, "Draw list mid count mismatch")
local listFront = AnimSprites.sortedDrawList({}, 201, 999)
assert(#listFront == 1 and listFront[1] == spFront, "Draw list front count mismatch")
print("[ok] Multi-layer Z-range filtering verified!")
print("\n=== ALL RENDERING & KINEMATICS VERIFICATIONS PASSED! ===")
+272
View File
@@ -0,0 +1,272 @@
-- Comprehensive unit & integration tests for Game 3 battle status effect & residual timing.
local Battle = require("src.core.game3.battle.init")
local State = require("src.core.game3.battle.state")
local Engine = require("src.core.game3.battle.engine")
local Residuals = require("src.core.game3.battle.residuals")
local Adapter = require("src.core.game3.battle.adapter")
local Ui = require("src.core.game3.battle.ui")
local Commands = require("src.core.game3.battle.commands")
local Damage = require("src.core.game3.battle.damage")
local SwitchSeq = require("src.core.game3.battle.switch_seq")
local function check(cond, msg)
if not cond then
error("[FAIL] " .. tostring(msg), 2)
end
print("[PASS] " .. tostring(msg))
end
local function eq(a, b, msg)
if a ~= b then
error(string.format("[FAIL] %s: expected %s, got %s", tostring(msg), tostring(b), tostring(a)), 2)
end
print("[PASS] " .. tostring(msg))
end
print("=== [TEST 1] Sequential Residual Events & Speed Ordering ===")
do
-- Charizard (speed 100, player) vs Blastoise (speed 78, enemy)
-- Both afflicted with status (Charizard: PSN, Blastoise: BRN)
local pParty = {
{ species = 6, level = 50, hp = 100, maxHp = 100, speed = 100, status = "PSN", moves = { 33 }, pp = { 35 } },
}
local eParty = {
{ species = 9, level = 50, hp = 80, maxHp = 80, speed = 78, status = "BRN", moves = { 33 }, pp = { 35 } },
}
local st = State.new({
wild = true,
playerParty = pParty,
foeParty = eParty,
})
local ad = Adapter.new(st, function() end)
local events = Engine.collectResidualEvents(st, ad)
eq(#events, 2, "collected exactly 2 discrete residual events")
eq(events[1].target.side, "player", "event 1 resolved for faster battler (player Charizard)")
eq(events[1].msgs[1], "CHARIZARD is hurt by poison!", "event 1 has correct poison text")
eq(events[1].hpChanges[1].side, "player", "event 1 hp change is on player")
eq(events[1].hpChanges[1].from, 100, "event 1 hp from 100")
local pLoss = math.max(1, math.floor(ad:maxHp(st.player) / 8))
local eLoss = math.max(1, math.floor(ad:maxHp(st.enemy) / 8))
eq(events[1].hpChanges[1].to, 100 - pLoss, "event 1 hp to 100 - pLoss")
eq(events[2].target.side, "enemy", "event 2 resolved for slower battler (enemy Blastoise)")
eq(events[2].msgs[1], "BLASTOISE is hurt by its burn!", "event 2 has correct burn text")
eq(events[2].hpChanges[1].side, "enemy", "event 2 hp change is on enemy")
eq(events[2].hpChanges[1].from, 80, "event 2 hp from 80")
eq(events[2].hpChanges[1].to, 80 - eLoss, "event 2 hp to 80 - eLoss")
end
print("\n=== [TEST 2] Speed Stages Invert Residual Execution Order ===")
do
-- Charizard (speed 100, player, stage -2 => 50) vs Blastoise (speed 78, enemy, stage 0 => 78)
local pParty = {
{ species = 6, level = 50, hp = 100, maxHp = 100, speed = 100, status = "PSN", moves = { 33 }, pp = { 35 } },
}
local eParty = {
{ species = 9, level = 50, hp = 80, maxHp = 80, speed = 78, status = "BRN", moves = { 33 }, pp = { 35 } },
}
local st = State.new({
wild = true,
playerParty = pParty,
foeParty = eParty,
})
st.player.stages.speed = -2
local ad = Adapter.new(st, function() end)
local events = Engine.collectResidualEvents(st, ad)
eq(#events, 2, "collected 2 discrete residual events")
eq(events[1].target.side, "enemy", "event 1 is now enemy Blastoise (faster due to player speed stage drop)")
eq(events[2].target.side, "player", "event 2 is player Charizard")
end
print("\n=== [TEST 3] Leech Seed Discrete Event with Sapped Damage & Heal ===")
do
local pParty = {
{ species = 1, level = 20, hp = 40, maxHp = 50, speed = 50, status = 0, moves = { 33 }, pp = { 35 } },
}
local eParty = {
{ species = 16, level = 20, hp = 30, maxHp = 50, speed = 60, status = 0, moves = { 33 }, pp = { 35 } },
}
local st = State.new({
wild = true,
playerParty = pParty,
foeParty = eParty,
})
local ad = Adapter.new(st, function() end)
-- Enemy is seeded by player
st.enemy.expSeeded = true
st.enemy.expSeedSource = st.player
local events = Engine.collectResidualEvents(st, ad)
eq(#events, 1, "collected 1 Leech Seed event")
eq(events[1].phase, "leech_seed", "event phase is leech_seed")
check(events[1].msgs[1]:find("sapped by LEECH SEED!"), "contains sapped by leech seed text")
eq(#events[1].hpChanges, 2, "2 hp changes: enemy took loss and player healed")
local pChange, eChange
for _, ch in ipairs(events[1].hpChanges) do
if ch.side == "player" then pChange = ch end
if ch.side == "enemy" then eChange = ch end
end
check(pChange ~= nil and pChange.to > pChange.from, "player received healing")
check(eChange ~= nil and eChange.to < eChange.from, "enemy took damage")
end
print("\n=== [TEST 4] Turn Execution with Residual Damage at End of Turn ===")
do
local pMon = Damage.ensureStats({ species = 25, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
local foeMon = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
pMon.status = "PSN"
local ok, err = Battle.start({
headless = true,
autoFight = false,
playerParty = { pMon },
foe = foeMon,
wild = true,
})
check(ok, "Battle started successfully")
Battle.update(0, nil) -- Advance intro to command
eq(Battle._phase, "command", "reached command phase for turn 1")
-- Queue Tackle for player and enemy
Battle._actions = {
{ kind = "move", user = "player", target = "enemy", move = 33, slot = 1 },
{ kind = "move", user = "enemy", target = "player", move = 33, slot = 1 },
}
Battle._actionI = 1
Battle._phase = "actions"
local hpBeforeResiduals = pMon.hp
local guard = 0
while Battle._phase == "actions" and guard < 10 do
guard = guard + 1
Battle.update(0, nil)
end
-- Turn ended: player mon took poison chip
check(pMon.hp < hpBeforeResiduals, "player mon took poison damage at end of turn")
eq(Battle._phase, "command", "cleanly transitioned to command phase for turn 2")
eq(#Ui._queue, 0, "no residual messages left in Ui._queue at start of next turn")
-- Verify Ui.log recorded the hurt by poison message during the turn
local foundPoisonMsg = false
for _, msg in ipairs(Ui.log() or {}) do
if msg:find("hurt by poison") then
foundPoisonMsg = true
break
end
end
check(foundPoisonMsg, "Ui.log recorded 'hurt by poison' text during the turn")
Battle.abort("win")
end
print("\n=== [TEST 5] Residual Faint Interrupts Flow & Triggers Switch/Party Menu ===")
do
local pMon1 = Damage.ensureStats({ species = 25, level = 10, maxHp = 30, moves = { 33 }, pp = { 35 } })
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
local foeMon = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
Battle.start({
headless = true,
autoFight = false,
playerParty = { pMon1, pMon2 },
foe = foeMon,
wild = true,
})
Battle.update(0, nil) -- Intro to command
local st = Battle.getState()
st.playerParty[1].hp = 1
st.playerParty[1].status = "PSN"
st.player.mon.hp = 1
st.player.status = "PSN"
-- Queue a move
Battle._actions = {
{ kind = "move", user = "player", target = "enemy", move = 33, slot = 1 },
}
Battle._actionI = 1
Battle._phase = "actions"
local guard = 0
while (Battle._phase == "actions" or Battle._phase == "switching") and guard < 30 do
guard = guard + 1
Battle.update(0, nil)
end
local st = Battle.getState()
eq(st.playerParty[1].hp, 0, "playerParty[1] fainted from poison")
-- In headless mode, handle_player_faint auto-sends out next living mon (slot 2)
eq(st.player.partyIndex, 2, "switched to conscious slot 2 after faint")
eq(Battle._phase, "command", "ready for command after faint replacement")
Battle.abort("win")
end
print("\n=== [TEST 6] Mutual Faint Resolution at End of Turn ===")
do
-- Both player and enemy have 1 HP and are poisoned; both have reserves
local pMon1 = Damage.ensureStats({ species = 1, level = 10, maxHp = 30, moves = { 33 }, pp = { 35 } })
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
local fMon1 = Damage.ensureStats({ species = 16, level = 10, maxHp = 30, moves = { 33 }, pp = { 35 } })
local fMon2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
Battle.start({
headless = true,
autoFight = false,
playerParty = { pMon1, pMon2 },
foeParty = { fMon1, fMon2 },
wild = false,
})
Battle.update(0, nil)
local st = Battle.getState()
st.playerParty[1].hp = 1
st.playerParty[1].status = "PSN"
st.player.mon.hp = 1
st.player.status = "PSN"
st.foeParty[1].hp = 1
st.foeParty[1].status = "PSN"
st.enemy.mon.hp = 1
st.enemy.status = "PSN"
Battle._actions = {}
Battle._actionI = 1
Battle._phase = "actions"
local guard = 0
while (Battle._phase == "actions" or Battle._phase == "switching") and guard < 30 do
guard = guard + 1
Battle.update(0, nil)
end
eq(st.playerParty[1].hp, 0, "player slot 1 fainted")
eq(st.foeParty[1].hp, 0, "enemy slot 1 fainted")
check(not st.over, "battle is NOT over because both have living reserves")
eq(st.enemy.partyIndex, 2, "enemy sent out next mon (slot 2)")
eq(st.player.partyIndex, 2, "player sent out next mon (slot 2)")
Battle.abort("win")
end
print("\n=== [TEST 7] Headless & Auto Battle Run to End Cleanly ===")
do
local pParty = {
{ species = 25, level = 20, hp = 50, maxHp = 50, speed = 90, status = "PSN", moves = { 33 }, pp = { 35 } },
}
local eParty = {
{ species = 16, level = 5, hp = 15, maxHp = 15, speed = 30, status = "BRN", moves = { 33 }, pp = { 35 } },
}
Battle.start({
wild = true,
playerParty = pParty,
foeParty = eParty,
headless = true,
auto = true,
})
local res = Battle.runToEnd()
eq(res, "win", "battle concluded with win in headless auto mode")
end
print("\nALL BATTLE STATUS & RESIDUAL TIMING TESTS PASSED CLEANLY! (100%)")
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env luajit
-- Dedicated Gen 3 Evolution Scene & Core Parity Tests
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local Pokemon = require("src.core.game3.pokemon")
local Evolution = require("src.core.game3.evolution")
local EvolutionScene = require("src.ui.game3.evolution_scene")
local Bag = require("src.core.game3.bag")
print("=== [TEST 1] Nickname Matching & Sanitization (EvolutionRenameMon) ===")
do
-- 1. Un-nicknamed (nil)
local mon1 = { species = 1, speciesId = 1, nickname = nil, name = "BULBASAUR" }
Evolution.renameMon(mon1, 1, 2)
check(mon1.nickname == "IVYSAUR" and mon1.name == "IVYSAUR", "un-nicknamed (nil) renamed to IVYSAUR")
-- 2. Un-nicknamed matching pre-evo name
local mon2 = { species = 1, speciesId = 1, nickname = "BULBASAUR", name = "BULBASAUR" }
Evolution.renameMon(mon2, 1, 2)
check(mon2.nickname == "IVYSAUR" and mon2.name == "IVYSAUR", "un-nicknamed matching pre-evo renamed to IVYSAUR")
-- 3. Un-nicknamed with null-padding / trailing spaces
local mon3 = { species = 1, speciesId = 1, nickname = "BULBASAUR\0\0 ", name = "BULBASAUR" }
Evolution.renameMon(mon3, 1, 2)
check(mon3.nickname == "IVYSAUR" and mon3.name == "IVYSAUR", "padded un-nicknamed renamed to IVYSAUR")
-- 4. Custom nickname preserved
local mon4 = { species = 1, speciesId = 1, nickname = "SPROUT", name = "SPROUT" }
Evolution.renameMon(mon4, 1, 2)
check(mon4.nickname == "SPROUT" and mon4.name == "SPROUT", "custom nickname SPROUT preserved on evolution")
end
print("=== [TEST 2] Everstone Protection ===")
do
local monNormal = { species = 1, speciesId = 1, level = 16, heldItem = 0 }
local target, _ = Evolution.levelTarget(monNormal)
check(target == 2, "normal Lv 16 Bulbasaur evolves into Ivysaur (species 2)")
local monEverstone = { species = 1, speciesId = 1, level = 16, heldItem = 197 } -- ITEM_EVERSTONE
local blockedTarget = Evolution.levelTarget(monEverstone)
check(blockedTarget == nil, "Everstone blocks level-up evolution")
-- Evolutionary stone bypasses Everstone
local clefairy = { species = 35, speciesId = 35, level = 16, heldItem = 197 }
local clefableTarget = Evolution.itemTarget(clefairy, 94) -- ITEM_MOON_STONE (94)
check(clefableTarget == 36, "Moon Stone bypasses Everstone on Clefairy (evolves to Clefable 36)")
end
print("=== [TEST 3] Stat Recalculation & HP Delta ===")
do
-- Alive mon gains HP delta
local monAlive = { species = 1, speciesId = 1, level = 16, hp = 15, maxHp = 40, iv = { 15, 15, 15, 15, 15, 15 }, ev = { 0, 0, 0, 0, 0, 0 } }
Evolution.apply(monAlive, 2)
check(monAlive.species == 2, "mutated to species 2 (Ivysaur)")
check(monAlive.maxHp > 40, "maxHp increased on evolution")
local delta = monAlive.maxHp - 40
check(monAlive.hp == 15 + delta, "current HP gained exact maxHp delta (" .. tostring(monAlive.hp) .. ")")
-- Fainted mon preserves 0 HP
local monFainted = { species = 1, speciesId = 1, level = 16, hp = 0, maxHp = 40, iv = { 15, 15, 15, 15, 15, 15 }, ev = { 0, 0, 0, 0, 0, 0 } }
Evolution.apply(monFainted, 2)
check(monFainted.hp == 0, "fainted mon remains at 0 HP after evolution")
end
print("=== [TEST 4] Shedinja Cloning & Sanitization (Nincada Lv 20 -> Ninjask + Shedinja) ===")
do
local bag = Bag.new()
Bag.add(bag, 4, 5) -- 5 standard Poké Balls
local party = {
{
species = 290, speciesId = 290, level = 20,
hp = 45, maxHp = 45,
heldItem = 197, -- Leftovers / Everstone
status = "PSN",
pokeball = 2, -- Ultra Ball
ability = 1,
moves = { 10, 20, 30, 40 },
iv = { 31, 31, 31, 31, 31, 31 },
ev = { 0, 0, 0, 0, 0, 0 },
}
}
local session = { party = party, dex = { seen = {}, caught = {} }, bag = bag }
local nincada = party[1]
Evolution.apply(nincada, 291, session, bag)
check(#party == 2, "Shedinja spawned into party (party size = 2)")
check(party[1].species == 291, "primary mon evolved into Ninjask (species 291)")
local shedinja = party[2]
check(shedinja.species == 292, "shedinja species is 292")
check(shedinja.maxHp == 1 and shedinja.hp == 1, "shedinja maxHp and hp are exactly 1")
check(shedinja.heldItem == 0, "shedinja heldItem is sanitized to 0 (no duplicate items)")
check(shedinja.status == 0, "shedinja status condition is sanitized to 0")
check(shedinja.pokeball == 4, "shedinja pokeball is standardized to ITEM_POKE_BALL (4)")
check(shedinja.ability == 25, "shedinja ability is Wonder Guard (25)")
check(Bag.get(bag, 4) == 4, "exactly 1 Poké Ball was deducted from bag (5 -> 4)")
check(session.dex.caught[292] == true, "Shedinja registered as Caught in Pokédex")
end
print("=== [TEST 5] EvolutionScene State Machine & B-Button Cancellation ===")
do
local mon = { species = 1, speciesId = 1, level = 16, hp = 40, maxHp = 40 }
local cancelled = false
local evolved = false
-- Start EvolutionScene with canStop = true
local ok = EvolutionScene.start(mon, 2, {
canStop = true,
onDone = function(result)
if result == "stopped" then cancelled = true end
if result == "evolved" then evolved = true end
end
})
check(ok == true, "EvolutionScene started")
check(EvolutionScene.isOpen() == true, "EvolutionScene is open")
-- Advance frames into cycle phase (reaches cycle at frame 156)
for _ = 1, 180 do
EvolutionScene.update(1 / 60)
end
check(EvolutionScene._state == "cycle", "entered cycle phase")
-- Simulate B-button press to cancel
local mockInput = {
isDown = function(_, k) return k == "b" end,
wasPressed = function(_, k) return k == "b" end,
}
EvolutionScene.handleInput(mockInput)
check(EvolutionScene._state == "cancel", "state transitioned to cancel on B-button")
check(mon.species == 1, "species remained Bulbasaur (species 1) after cancellation")
-- Advance text dismiss (first press skips typewriter, second dismisses)
EvolutionScene.handleInput(mockInput)
EvolutionScene.handleInput(mockInput)
check(EvolutionScene.isOpen() == false, "EvolutionScene closed after cancel")
check(cancelled == true, "onDone called with result 'stopped'")
end
print("=== [TEST 6] EvolutionScene Point-of-No-Return Completion ===")
do
local mon = { species = 1, speciesId = 1, level = 16, hp = 40, maxHp = 40 }
local completed = false
EvolutionScene.start(mon, 2, {
canStop = false,
headless = true,
onDone = function(result)
if result == "evolved" then completed = true end
end
})
-- Run through entire animation cycle past acceleration cutoff (speed >= 128)
for _ = 1, 800 do
EvolutionScene.update(1 / 60)
if EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then
local mockInput = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
EvolutionScene.handleInput(mockInput)
end
end
check(mon.species == 2, "species successfully mutated to Ivysaur (species 2)")
check(mon.nickname == "IVYSAUR", "nickname updated to IVYSAUR")
check(completed == true, "onDone called with result 'evolved'")
end
print("=== [TEST 7] EvolutionScene Audio Sequence & Map Music Restoration ===")
do
local Audio = require("src.core.game3.audio")
local playedSongs = {}
local origPlaySong = Audio.playSong
Audio.playSong = function(id, opts)
playedSongs[#playedSongs + 1] = id
return origPlaySong(id, opts)
end
Audio._mapSong = 279 -- Pallet Town
Audio._currentSong = { id = 279 }
local mon = { species = 19, speciesId = 19, level = 20, hp = 40, maxHp = 40, moves = { 33, 39, 43, 99 } }
local completed = false
EvolutionScene.start(mon, 20, {
canStop = false,
headless = true,
savedSong = 279,
onDone = function(result)
if result == "evolved" then completed = true end
end
})
check(EvolutionScene._savedSong == 279, "EvolutionScene savedSong preserved as 279")
-- Progress past congrats
for _ = 1, 800 do
EvolutionScene.update(1 / 60)
if EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then
local mockInput = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
EvolutionScene.handleInput(mockInput)
end
end
check(completed == true, "evolution completed")
check(playedSongs[#playedSongs] == 279, "Pallet Town map BGM (279) restored at end of evolution sequence")
Audio.playSong = origPlaySong
end
print("=== [TEST 8] EvolutionScene Post-Battle Victory Theme Resumption ===")
do
local Audio = require("src.core.game3.audio")
local playedSongs = {}
local origPlaySong = Audio.playSong
Audio.playSong = function(id, opts)
playedSongs[#playedSongs + 1] = id
return origPlaySong(id, opts)
end
Audio._mapSong = 279 -- Pallet Town
Audio._currentSong = { id = 311 } -- MUS_VICTORY_WILD
local mon = { species = 19, speciesId = 19, level = 20, hp = 40, maxHp = 40, moves = { 33, 39, 43, 99 } }
local completed = false
local EvoSeq = require("src.core.game3.battle.evo_seq")
local session = { party = { mon } }
EvoSeq.begin({ { mon = mon, target = 20 } }, {
session = session,
onDone = function() completed = true end
})
-- Advance frames and dismiss dialogs
for _ = 1, 800 do
EvoSeq.update()
EvolutionScene.update(1 / 60)
local Choice = package.loaded["src.ui.game3.choice"]
if Choice and Choice.active then
Choice.cancel()
end
if EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then
local mockInput = {
wasPressed = function(_, k) return k == "a" or k == "b" end,
isDown = function() return false end,
}
EvolutionScene.handleInput(mockInput)
end
end
EvoSeq.update()
check(completed == true, "battle evolution sequence finished")
check(playedSongs[#playedSongs] == 311, "Victory theme (311) resumed during and after post-battle evolution")
Audio.playSong = origPlaySong
end
if failed > 0 then
print(string.format("\n[FAILED] %d test(s) failed", failed))
os.exit(1)
else
print("\nALL 8 EVOLUTION SCENE TESTS PASSED CLEANLY!")
end
+509
View File
@@ -0,0 +1,509 @@
#!/usr/bin/env luajit
-- Gen 3 Party Item Use, TM Confirmation & Evolution Chaining Test Suite
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local Pokemon = require("src.core.game3.pokemon")
local Evolution = require("src.core.game3.evolution")
local ItemUse = require("src.core.game3.item_use")
local PartyMenu = require("src.ui.game3.party_menu")
local Bag = require("src.core.game3.bag")
local Message = require("src.ui.game3.message")
print("=== [TEST 1] Rare Candy Gating (Lv 100 Cap & 0 HP Protection) ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 5) -- 5 Rare Candies (id 68)
local session = { bag = bag }
-- 1. Lv 100 Pokémon
local mon100 = { species = 1, speciesId = 1, level = 100, hp = 200, maxHp = 200 }
local ok, reason, msgText = ItemUse.useRareCandy(session, mon100)
check(ok == false, "Rare Candy rejected on Lv 100 mon")
check(mon100.level == 100, "level remained 100")
check(Bag.get(bag, 68) == 5, "Rare Candy count not decremented (still 5)")
-- 2. 0 HP Fainted Pokémon
local monFainted = { species = 1, speciesId = 1, level = 16, hp = 0, maxHp = 40 }
local okFaint, reasonFaint, msgFaint = ItemUse.useRareCandy(session, monFainted)
check(okFaint == false, "Rare Candy rejected on 0 HP fainted mon")
check(monFainted.level == 16, "fainted mon level unchanged")
check(Bag.get(bag, 68) == 5, "Rare Candy count not decremented (still 5)")
end
print("=== [TEST 2] Rare Candy HP Delta & Stat Recalculation on Living Mon ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 5)
local session = { bag = bag }
local monAlive = {
species = 1, speciesId = 1, level = 16,
hp = 15, maxHp = 40,
iv = { 15, 15, 15, 15, 15, 15 }, ev = { 0, 0, 0, 0, 0, 0 }
}
local ok, reason, msgText = ItemUse.useRareCandy(session, monAlive)
check(ok == true, "Rare Candy accepted on living mon")
check(monAlive.level == 17, "level increased to 17")
check(monAlive.maxHp > 40, "maxHp increased on level up")
local delta = monAlive.maxHp - 40
check(monAlive.hp == 15 + delta, "current HP gained exact maxHp delta (" .. tostring(monAlive.hp) .. ")")
end
print("=== [TEST 3] TM Pre-Flight Checks & Known Move / Incompatibility Rejections ===")
do
-- Bulbasaur (species 1): can learn TM06 (Toxic / 294), cannot learn TM47 (Steel Wing / 335)
-- Bulbasaur knows Tackle (33)
local bulbasaur = {
species = 1, speciesId = 1, level = 10,
moves = { 33 }, -- Tackle
}
-- Test with real TM item: TM06 (Toxic)
local statusOk, prompt, moveId, moveName = ItemUse.checkTmPreflight(bulbasaur, 294) -- TM06 Toxic
check(statusOk == "ok", "Bulbasaur compatible with TM06 (Toxic)")
check(prompt:find("Booted up a TM", 1, true) ~= nil, "prompt contains 'Booted up a TM'")
-- Teach Toxic to Bulbasaur
bulbasaur.moves[#bulbasaur.moves + 1] = moveId
local statusKnows, msgKnows = ItemUse.checkTmPreflight(bulbasaur, 294)
check(statusKnows == "knows", "attempting to teach already known move returns 'knows'")
check(msgKnows:find("already knows", 1, true) ~= nil, "message states mon already knows move")
-- Test incompatible TM (e.g. TM47 Steel Wing / 335 on Bulbasaur)
local statusIncompat, msgIncompat = ItemUse.checkTmPreflight(bulbasaur, 335) -- TM47 Steel Wing
check(statusIncompat == "incompatible", "Bulbasaur incompatible with TM47 (Steel Wing)")
check(msgIncompat:find("can't learn", 1, true) ~= nil, "message states mon can't learn move")
end
print("=== [TEST 4] PartyMenu TM Yes/No Confirmation & Move Learning Flow ===")
do
local bag = Bag.new()
Bag.add(bag, 294, 1) -- 1 TM06 (Toxic)
local party = {
{
species = 1, speciesId = 1, level = 10,
moves = { 33 }, -- Tackle (1/4 slots)
hp = 30, maxHp = 30,
}
}
local session = { party = party, bag = bag }
-- Show PartyMenu in "use" mode for TM06
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 294,
mode = "use",
})
check(PartyMenu.mode == "use", "PartyMenu opened in 'use' mode")
-- Press A on Bulbasaur
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
PartyMenu.handleInput(mockInputA)
check(PartyMenu.mode == "yesno", "PartyMenu opened Yes/No confirmation prompt")
check(PartyMenu._yesNoPrompt:find("Teach TOXIC to BULBASAUR?", 1, true) ~= nil, "Yes/No prompt contains TM teach confirmation")
-- Press A on YES
PartyMenu.handleInput(mockInputA)
check(Pokemon.knowsMove(party[1], 92), "Bulbasaur learned Toxic (move 92)")
check(Bag.get(bag, 294) == 0, "TM06 consumed from bag on successful teach")
check(PartyMenu.mode == "message", "PartyMenu showed learned message")
-- Dismiss message
PartyMenu.handleInput(mockInputA)
check(PartyMenu.isOpen() == false, "PartyMenu closed after single-use TM was taught")
end
print("=== [TEST 5] Rare Candy Multi-Use Persistence vs Empty Bag Close ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 2) -- 2 Rare Candies
local party = {
{
species = 19, speciesId = 19, level = 5, -- Rattata Lv 5 (evolves at 20)
hp = 20, maxHp = 20,
moves = { 33 },
}
}
local session = { party = party, bag = bag }
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 68,
mode = "use",
})
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
-- Use 1st Rare Candy (count: 2 -> 1)
PartyMenu.handleInput(mockInputA)
check(party[1].level == 6, "Rattata leveled up to 6")
check(Bag.get(bag, 68) == 1, "Rare Candy count decremented to 1")
-- Complete HP anim if active, then verify stat_growth mode
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.mode == "stat_growth", "PartyMenu showing stat growth window")
check(PartyMenu._statGrowthPage == 1, "stat growth on page 1 (deltas)")
-- Advance to Page 2 (totals)
PartyMenu.handleInput(mockInputA)
check(PartyMenu._statGrowthPage == 2, "stat growth on page 2 (totals)")
-- Advance out of stat growth to next item use
PartyMenu.handleInput(mockInputA)
check(PartyMenu.mode == "use", "PartyMenu persisted in 'use' mode for multi-item usage (1 candy left)")
check(PartyMenu.isOpen() == true, "PartyMenu remained open")
-- Use 2nd Rare Candy (count: 1 -> 0)
PartyMenu.handleInput(mockInputA)
check(party[1].level == 7, "Rattata leveled up to 7")
check(Bag.get(bag, 68) == 0, "Rare Candy count decremented to 0")
-- Complete HP anim if active, then advance stat growth pages
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.mode == "stat_growth", "PartyMenu showing stat growth window for 2nd candy")
PartyMenu.handleInput(mockInputA) -- Page 1 -> Page 2
PartyMenu.handleInput(mockInputA) -- Page 2 -> Move learn queue
-- Dismiss learned move message (Quick Attack at Lv 7)
if PartyMenu.mode == "message" then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.isOpen() == false, "PartyMenu closed when item count reached 0")
end
print("=== [TEST 6] Rare Candy Evolution Chaining & Post-Evolution Return Mode ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 1) -- 1 Rare Candy
local party = {
{
species = 19, speciesId = 19, level = 19, -- Rattata Lv 19 -> evolves to Raticate 20 at Lv 20
hp = 45, maxHp = 45,
moves = { 33 },
}
}
local session = { party = party, bag = bag }
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 68,
mode = "use",
})
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
-- Use Rare Candy
PartyMenu.handleInput(mockInputA)
check(party[1].level == 20, "Rattata reached Lv 20")
-- Complete HP anim and advance stat growth pages
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.mode == "stat_growth", "PartyMenu showing stat growth window")
PartyMenu.handleInput(mockInputA) -- Page 1 -> Page 2
PartyMenu.handleInput(mockInputA) -- Page 2 -> Move learn queue
-- Dismiss learned move message (Focus Energy at Lv 20) -> triggers EvolutionScene
if PartyMenu.mode == "message" then PartyMenu.handleInput(mockInputA) end
local EvolutionScene = require("src.ui.game3.evolution_scene")
check(EvolutionScene.isOpen() == true, "EvolutionScene opened on Rare Candy level-up to threshold")
-- Advance EvolutionScene animation to completion
for _ = 1, 800 do
EvolutionScene.update(1 / 60)
local SummaryMenu = require("src.ui.game3.summary_menu")
if SummaryMenu.isOpen() then
SummaryMenu.handleInput(mockInputA)
elseif EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then
local Choice = require("src.ui.game3.choice")
if Choice.active then
Choice.confirm()
end
EvolutionScene.handleInput(mockInputA)
end
end
check(party[1].species == 20, "mon evolved into Raticate (species 20)")
check(EvolutionScene.isOpen() == false, "EvolutionScene closed after evolution")
check(PartyMenu.mode == "list", "PartyMenu returned to 'list' mode after evolution (exited multi-use)")
PartyMenu.close()
end
print("=== [TEST 7] Chrome Isolation (No Overworld Message.isOpen Leaks) ===")
do
local bag = Bag.new()
Bag.add(bag, 13, 2) -- Potion
local party = {
{ species = 1, speciesId = 1, level = 10, hp = 10, maxHp = 30 }
}
local session = { party = party, bag = bag }
-- Verify Message is initially closed
Message.close()
check(Message.isOpen() == false, "overworld Message is closed before party item use")
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 13,
mode = "use",
})
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
PartyMenu.handleInput(mockInputA)
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(Message.isOpen() == false, "overworld Message remained closed during PartyMenu item use (no double chrome)")
check(PartyMenu.mode == "message", "PartyMenu is handling its own message internally")
PartyMenu.close()
end
print("=== [TEST 8] String Species Mon & 4-Move Replace Flow into Evolution ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 1) -- 1 Rare Candy
local party = {
{
species = "RATTATA", -- String species name format from save/world
level = 19,
hp = 45, maxHp = 45,
moves = { 33, 39, 98, 158 }, -- 4 moves: Tackle, Tail Whip, Quick Attack, Hyper Fang
}
}
local session = { party = party, bag = bag }
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 68,
mode = "use",
})
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
-- 1. Use Rare Candy
PartyMenu.handleInput(mockInputA)
check(party[1].level == 20, "String species Rattata reached Lv 20")
-- 2. Fast forward HP anim & advance stat growth pages
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.mode == "stat_growth", "PartyMenu showing stat growth window")
PartyMenu.handleInput(mockInputA) -- Page 1 -> Page 2
PartyMenu.handleInput(mockInputA) -- Page 2 -> Move learn queue
-- 3. Learn move page 1: "Rattata wants to learn the move Focus Energy."
check(PartyMenu.mode == "message", "PartyMenu showing 4-move learn move message page 1")
PartyMenu.handleInput(mockInputA)
-- 4. Learn move page 2: "However, Rattata already knows four moves."
check(PartyMenu.mode == "message", "PartyMenu showing 4-move learn move message page 2")
PartyMenu.handleInput(mockInputA)
-- 5. Delete prompt: "Should a move be deleted and replaced with Focus Energy?" (yes/no)
check(PartyMenu.mode == "yesno", "PartyMenu showing delete move yes/no prompt")
PartyMenu.handleInput(mockInputA) -- choose YES
-- 6. SummaryMenu opened in select_move mode for move replacement
local SummaryMenu = require("src.ui.game3.summary_menu")
check(SummaryMenu.isOpen() == true, "SummaryMenu opened for move selection")
check(SummaryMenu._mode == "select_move", "SummaryMenu mode is select_move")
SummaryMenu.handleInput(mockInputA) -- choose Move 1 (Tackle) to replace
-- 7. Poof messages: "1, 2, and... Poof!"
check(PartyMenu.mode == "message", "PartyMenu showing Poof message")
PartyMenu.handleInput(mockInputA)
-- 8. Forgot message: "Rattata forgot how to use Tackle."
check(PartyMenu.mode == "message", "PartyMenu showing forgot old move message")
PartyMenu.handleInput(mockInputA)
-- 9. And... message
check(PartyMenu.mode == "message", "PartyMenu showing And... message")
PartyMenu.handleInput(mockInputA)
-- 10. Learned message: "Rattata learned Focus Energy!"
check(PartyMenu.mode == "message", "PartyMenu showing learned new move message")
PartyMenu.handleInput(mockInputA)
-- 11. Chaining into EvolutionScene!
local EvolutionScene = require("src.ui.game3.evolution_scene")
check(EvolutionScene.isOpen() == true, "EvolutionScene opened for string species Rattata at Lv 20")
-- Advance EvolutionScene animation to completion
for _ = 1, 800 do
EvolutionScene.update(1 / 60)
if SummaryMenu.isOpen() then
SummaryMenu.handleInput(mockInputA)
elseif EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then
local Choice = require("src.ui.game3.choice")
if Choice.active then
Choice.confirm()
end
EvolutionScene.handleInput(mockInputA)
end
end
check(party[1].species == 20 or party[1].speciesId == 20, "mon evolved into Raticate (species 20)")
check(EvolutionScene.isOpen() == false, "EvolutionScene closed after evolution")
check(PartyMenu.mode == "list", "PartyMenu returned to 'list' mode after evolution")
PartyMenu.close()
end
print("=== [TEST 9] 4-Move Replacement on Specific Slots & B-Cancel Refusal ===")
do
local bag = Bag.new()
Bag.add(bag, 68, 1) -- 1 Rare Candy
local party = {
{
species = 19, -- Rattata
level = 19,
hp = 45, maxHp = 45,
moves = { 33, 39, 98, 158 }, -- Tackle, Tail Whip, Quick Attack, Hyper Fang
}
}
local session = { party = party, bag = bag }
PartyMenu.show(party, nil, {
session = session,
bag = bag,
item = 68,
mode = "use",
})
local mockInputA = {
wasPressed = function(_, k) return k == "a" end,
isDown = function() return false end,
}
local mockInputDown = {
wasPressed = function(_, k) return k == "down" end,
isDown = function() return false end,
}
-- Use candy to reach Lv 20
PartyMenu.handleInput(mockInputA)
if PartyMenu._hpAnim then PartyMenu.handleInput(mockInputA) end
check(PartyMenu.mode == "stat_growth", "PartyMenu showing stat growth window for test 9")
PartyMenu.handleInput(mockInputA) -- Page 1 -> Page 2
PartyMenu.handleInput(mockInputA) -- Page 2 -> Move learn queue
-- Dismiss "wants to learn" and "knows four moves"
PartyMenu.handleInput(mockInputA)
PartyMenu.handleInput(mockInputA)
-- Should be at delete prompt (yes/no)
check(PartyMenu.mode == "yesno", "at delete prompt")
PartyMenu.handleInput(mockInputA) -- select YES
-- Should be at SummaryMenu select_move prompt
local SummaryMenu = require("src.ui.game3.summary_menu")
check(SummaryMenu.isOpen() == true, "at SummaryMenu select_move prompt")
check(SummaryMenu._mode == "select_move", "SummaryMenu mode is select_move")
-- Navigate down to move 4 (Hyper Fang, slot 4)
SummaryMenu.handleInput(mockInputDown)
SummaryMenu.handleInput(mockInputDown)
SummaryMenu.handleInput(mockInputDown)
check(SummaryMenu._moveCursor == 4, "cursor at slot 4")
-- Confirm replacing slot 4
SummaryMenu.handleInput(mockInputA)
check(party[1].moves[4] == 116, "slot 4 replaced with Focus Energy (116)")
-- Dismiss Poof, forgot, And..., learned
PartyMenu.handleInput(mockInputA)
PartyMenu.handleInput(mockInputA)
PartyMenu.handleInput(mockInputA)
PartyMenu.handleInput(mockInputA)
-- Verify EvolutionScene launched without softlock
local EvolutionScene = require("src.ui.game3.evolution_scene")
local Stack = require("src.ui.game3.stack")
check(EvolutionScene.isOpen() == true, "EvolutionScene opened after slot 4 learn")
EvolutionScene.open = false
Stack.pop("evolution_scene")
PartyMenu.close()
end
print("=== [TEST 10] PartyMenu Cancel Button Navigation & Dismissal ===")
do
local party = {
{ species = 1, speciesId = 1, level = 10, hp = 30, maxHp = 30 },
{ species = 4, speciesId = 4, level = 10, hp = 30, maxHp = 30 },
{ species = 7, speciesId = 7, level = 10, hp = 30, maxHp = 30 },
}
local closed = false
PartyMenu.show(party, nil, {
onClose = function() closed = true end,
})
check(PartyMenu.isOpen() == true, "PartyMenu is open")
check(PartyMenu.cursor == 1, "Initial cursor at slot 1")
local function press(btn)
local inp = {
wasPressed = function(_, k) return k == btn end,
isDown = function() return false end,
}
PartyMenu.handleInput(inp)
end
-- UP from slot 1 wraps to slot 7 (Cancel)
press("up")
check(PartyMenu.cursor == 7, "UP from slot 1 moved to slot 7 (Cancel)")
-- UP from slot 7 wraps to slot 3 (last party member)
press("up")
check(PartyMenu.cursor == 3, "UP from slot 7 moved to slot 3 (last mon)")
-- DOWN from slot 3 moves to slot 7 (Cancel)
press("down")
check(PartyMenu.cursor == 7, "DOWN from slot 3 moved to slot 7 (Cancel)")
-- DOWN from slot 7 wraps to slot 1
press("down")
check(PartyMenu.cursor == 1, "DOWN from slot 7 wrapped to slot 1")
-- RIGHT from slot 1 moves to slot 2 (right column)
press("right")
check(PartyMenu.cursor == 2, "RIGHT from slot 1 moved to slot 2")
-- LEFT from slot 2 moves to slot 1 (left column)
press("left")
check(PartyMenu.cursor == 1, "LEFT from slot 2 moved to slot 1")
-- Move to Cancel and press A to close
press("up")
check(PartyMenu.cursor == 7, "At slot 7 (Cancel)")
press("a")
check(PartyMenu.isOpen() == false, "Pressing A on Cancel button closes PartyMenu")
check(closed == true, "onClose callback invoked")
end
if failed > 0 then
print(string.format("\n[FAILED] %d test(s) failed", failed))
os.exit(1)
else
print("\nALL PARTY ITEM USE & PARITY TESTS PASSED CLEANLY!")
end
+178
View File
@@ -0,0 +1,178 @@
-- Test suite for GBA 1:1 text colors, stdpal_0 palette, 3-slot colors, and control codes.
local function assert_eq(a, b, msg)
if a ~= b then
error(string.format("%s: expected %s, got %s", msg or "assert_eq failed", tostring(b), tostring(a)))
end
end
local function assert_near(a, b, eps, msg)
eps = eps or 0.001
if math.abs(a - b) > eps then
error(string.format("%s: expected %s, got %s (diff %s)", msg or "assert_near failed", tostring(b), tostring(a), tostring(math.abs(a - b))))
end
end
print("=== [TEST 1] STDPAL Palette & COLOR_IDS 1:1 Verification ===")
local FrlgFont = require("src.ui.game3.frlg_font")
assert_eq(FrlgFont.COLOR_IDS.TRANSPARENT, 0, "COLOR_IDS.TRANSPARENT is 0")
assert_eq(FrlgFont.COLOR_IDS.WHITE, 1, "COLOR_IDS.WHITE is 1")
assert_eq(FrlgFont.COLOR_IDS.DARK_GRAY, 2, "COLOR_IDS.DARK_GRAY is 2")
assert_eq(FrlgFont.COLOR_IDS.LIGHT_GRAY, 3, "COLOR_IDS.LIGHT_GRAY is 3")
assert_eq(FrlgFont.COLOR_IDS.RED, 4, "COLOR_IDS.RED is 4")
assert_eq(FrlgFont.COLOR_IDS.LIGHT_RED, 5, "COLOR_IDS.LIGHT_RED is 5")
assert_eq(FrlgFont.COLOR_IDS.GREEN, 6, "COLOR_IDS.GREEN is 6")
assert_eq(FrlgFont.COLOR_IDS.LIGHT_GREEN, 7, "COLOR_IDS.LIGHT_GREEN is 7")
assert_eq(FrlgFont.COLOR_IDS.BLUE, 8, "COLOR_IDS.BLUE is 8")
assert_eq(FrlgFont.COLOR_IDS.LIGHT_BLUE, 9, "COLOR_IDS.LIGHT_BLUE is 9")
-- Check exact normalized RGB values matching stdpal_0.pal
assert_eq(FrlgFont.STDPAL[0][4], 0, "STDPAL[0] alpha is 0 (Transparent)")
assert_near(FrlgFont.STDPAL[1][1], 1.0, 0.001, "STDPAL[1] White red")
assert_near(FrlgFont.STDPAL[2][1], 98 / 255, 0.001, "STDPAL[2] Dark Gray red (98/255)")
assert_near(FrlgFont.STDPAL[3][1], 213 / 255, 0.001, "STDPAL[3] Light Gray red (213/255)")
assert_near(FrlgFont.STDPAL[4][1], 230 / 255, 0.001, "STDPAL[4] Red red (230/255)")
assert_near(FrlgFont.STDPAL[5][1], 255 / 255, 0.001, "STDPAL[5] Light Red red (255/255)")
assert_near(FrlgFont.STDPAL[6][1], 32 / 255, 0.001, "STDPAL[6] Green red (32/255)")
assert_near(FrlgFont.STDPAL[7][1], 148 / 255, 0.001, "STDPAL[7] Light Green red (148/255)")
assert_near(FrlgFont.STDPAL[8][1], 49 / 255, 0.001, "STDPAL[8] Blue red (49/255)")
assert_near(FrlgFont.STDPAL[9][1], 164 / 255, 0.001, "STDPAL[9] Light Blue red (164/255)")
print("[ok] STDPAL 1:1 verified")
print("=== [TEST 2] 3-Slot Color Architecture (fg, shadow, bg) ===")
assert_eq(FrlgFont.COLOR.NORMAL.fg, FrlgFont.STDPAL[2], "NORMAL fg is DARK_GRAY")
assert_eq(FrlgFont.COLOR.NORMAL.shadow, FrlgFont.STDPAL[3], "NORMAL shadow is LIGHT_GRAY")
assert_eq(FrlgFont.COLOR.NORMAL.bg, FrlgFont.STDPAL[0], "NORMAL bg is TRANSPARENT")
-- Two-tone Pokémon gender symbols: Light fg + Dark shadow
assert_eq(FrlgFont.COLOR.MALE.fg, FrlgFont.STDPAL[9], "MALE fg is LIGHT_BLUE")
assert_eq(FrlgFont.COLOR.MALE.shadow, FrlgFont.STDPAL[8], "MALE shadow is BLUE")
assert_eq(FrlgFont.COLOR.MALE.bg, FrlgFont.STDPAL[0], "MALE bg is TRANSPARENT")
assert_eq(FrlgFont.COLOR.FEMALE.fg, FrlgFont.STDPAL[5], "FEMALE fg is LIGHT_RED")
assert_eq(FrlgFont.COLOR.FEMALE.shadow, FrlgFont.STDPAL[4], "FEMALE shadow is RED")
assert_eq(FrlgFont.COLOR.FEMALE.bg, FrlgFont.STDPAL[0], "FEMALE bg is TRANSPARENT")
-- NPC Dialogue colors: Dark fg + Light Gray shadow
assert_eq(FrlgFont.COLOR.MALE_NPC.fg, FrlgFont.STDPAL[8], "MALE_NPC fg is BLUE")
assert_eq(FrlgFont.COLOR.MALE_NPC.shadow, FrlgFont.STDPAL[3], "MALE_NPC shadow is LIGHT_GRAY")
assert_eq(FrlgFont.COLOR.FEMALE_NPC.fg, FrlgFont.STDPAL[4], "FEMALE_NPC fg is RED")
assert_eq(FrlgFont.COLOR.FEMALE_NPC.shadow, FrlgFont.STDPAL[3], "FEMALE_NPC shadow is LIGHT_GRAY")
assert_eq(FrlgFont.COLOR.WHITE.fg, FrlgFont.STDPAL[1], "WHITE fg is WHITE")
assert_eq(FrlgFont.COLOR.WHITE.shadow, FrlgFont.STDPAL[2], "WHITE shadow is DARK_GRAY")
print("[ok] 3-slot architecture verified")
print("=== [TEST 3] sTextColorTable & NPC Text Color Lookups ===")
-- Female NPCs: Nurse (64), Cable Club Receptionist (65), Daisy (76), Mom (88), Lass (22), Beauty (29)
assert_eq(FrlgFont.getNpcTextColor(64), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Nurse Joy is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(65), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Cable Club Receptionist is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(76), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Daisy is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(88), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Mom is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(22), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Lass is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(29), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Beauty is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(81), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Misty is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(83), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Erika is FEMALE")
assert_eq(FrlgFont.getNpcTextColor(85), FrlgFont.NPC_TEXT_COLOR.FEMALE, "Sabrina is FEMALE")
-- Male NPCs: Prof Oak (71), Blue (72), Bill (73), Clerk (68), Youngster (18), Brock (80), Giovanni (87)
assert_eq(FrlgFont.getNpcTextColor(71), FrlgFont.NPC_TEXT_COLOR.MALE, "Prof Oak is MALE")
assert_eq(FrlgFont.getNpcTextColor(72), FrlgFont.NPC_TEXT_COLOR.MALE, "Blue is MALE")
assert_eq(FrlgFont.getNpcTextColor(73), FrlgFont.NPC_TEXT_COLOR.MALE, "Bill is MALE")
assert_eq(FrlgFont.getNpcTextColor(68), FrlgFont.NPC_TEXT_COLOR.MALE, "Clerk is MALE")
assert_eq(FrlgFont.getNpcTextColor(18), FrlgFont.NPC_TEXT_COLOR.MALE, "Youngster is MALE")
assert_eq(FrlgFont.getNpcTextColor(80), FrlgFont.NPC_TEXT_COLOR.MALE, "Brock is MALE")
assert_eq(FrlgFont.getNpcTextColor(87), FrlgFont.NPC_TEXT_COLOR.MALE, "Giovanni is MALE")
-- Neutral Objects: Item Ball (92), Sign (103), Rock Smash Rock (96), Cut Tree (95)
assert_eq(FrlgFont.getNpcTextColor(92), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "Item ball is NEUTRAL")
assert_eq(FrlgFont.getNpcTextColor(103), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "Sign is NEUTRAL")
assert_eq(FrlgFont.getNpcTextColor(96), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "Rock is NEUTRAL")
-- Pokémon Sprites: Pikachu (120), Snorlax (109), Clefairy (113)
assert_eq(FrlgFont.getNpcTextColor(120), FrlgFont.NPC_TEXT_COLOR.MON, "Pikachu is MON")
assert_eq(FrlgFont.getNpcTextColor(109), FrlgFont.NPC_TEXT_COLOR.MON, "Snorlax is MON")
-- Fallbacks (nil, negative, out of range)
assert_eq(FrlgFont.getNpcTextColor(nil), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "nil defaults to NEUTRAL")
assert_eq(FrlgFont.getNpcTextColor(-1), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "-1 defaults to NEUTRAL")
assert_eq(FrlgFont.getNpcTextColor(999), FrlgFont.NPC_TEXT_COLOR.NEUTRAL, "out of range defaults to NEUTRAL")
-- colorForNpc helper
local nurseColor = FrlgFont.colorForNpc(64)
assert_eq(nurseColor.fg, FrlgFont.STDPAL[4], "Nurse color fg is RED")
local oakColor = FrlgFont.colorForNpc(71)
assert_eq(oakColor.fg, FrlgFont.STDPAL[8], "Oak color fg is BLUE")
local signColor = FrlgFont.colorForNpc(103)
assert_eq(signColor.fg, FrlgFont.STDPAL[2], "Sign color fg is DARK_GRAY")
print("[ok] sTextColorTable lookups verified")
print("=== [TEST 4] Bytecode & Macro Token Scanning (Null Bytes & Variable Length) ===")
-- Scan string with embedded {FONT_FEMALE} and {COLOR BLUE}
local testStr = "{FONT_FEMALE}Hello {COLOR BLUE}World!"
local tokens = {}
for ttype, val, colors in FrlgFont.scanTokens(testStr, FrlgFont.COLOR.NORMAL) do
tokens[#tokens + 1] = { ttype = ttype, val = val, fg = colors.fg }
end
assert_eq(tokens[1].ttype, "ctrl", "first token is ctrl FONT_FEMALE")
assert_eq(tokens[1].fg, FrlgFont.STDPAL[4], "colors became FEMALE (red)")
assert_eq(tokens[2].ttype, "char", "second token is 'H'")
assert_eq(tokens[2].val, "H", "char value is 'H'")
-- Check character count ignoring control tags
local charCount = FrlgFont.countChars(testStr)
assert_eq(charCount, 12, "countChars counts only 'Hello World!' (12 chars)")
-- Test raw 0xFC bytecodes with null byte \x00
-- FONT_MALE: \xFC \x04 \x08 \x00 \x03
local rawStr = "\xFC\x04\x08\x00\x03Test\xFC\x01\x04Red"
local rawTokens = {}
for ttype, val, colors in FrlgFont.scanTokens(rawStr, FrlgFont.COLOR.NORMAL) do
rawTokens[#rawTokens + 1] = { ttype = ttype, val = val, fg = colors.fg, bg = colors.bg, shadow = colors.shadow }
end
assert_eq(rawTokens[1].ttype, "ctrl", "raw token 1 is ctrl")
assert_eq(rawTokens[1].fg, FrlgFont.STDPAL[8], "raw fg set to Blue (8)")
assert_eq(rawTokens[1].bg, FrlgFont.STDPAL[0], "raw bg set to Transparent (0)")
assert_eq(rawTokens[1].shadow, FrlgFont.STDPAL[3], "raw shadow set to Light Gray (3)")
assert_eq(rawTokens[2].val, "T", "first letter after raw control code is 'T'")
assert_eq(FrlgFont.countChars(rawStr), 7, "rawStr char count is 7 ('TestRed')")
print("[ok] Token scanner handles null bytes and variable length bytecode cleanly")
print("=== [TEST 5] TextIR Tag Expansion & Word Wrapping ===")
local TextIR = require("src.core.game3.scripting.text_ir")
local ir = TextIR.fromAscii("{FONT_FEMALE}Nurse Joy: Welcome to\\nthe POKéMON CENTER!\\pWe restore your POKéMON.")
local textBoxStr = TextIR.toTextBox(ir, { maxWidth = 208 })
-- Must contain two pages split by \f
assert(textBoxStr:find("\f"), "toTextBox created multi-page text with \\f")
assert(textBoxStr:find("{FONT_FEMALE}"), "toTextBox preserved {FONT_FEMALE} tag")
print("[ok] TextIR tag preservation and box formatting verified")
print("=== [TEST 6] Message.show Ambient Color Resolution ===")
local Message = require("src.ui.game3.message")
-- Nurse Joy dialogue
Message.show("Welcome to the Pokémon Center!", { gfxId = 64 })
assert_eq(Message._colors.fg, FrlgFont.STDPAL[4], "Message with Nurse Joy gfxId gets RED text")
-- Prof Oak dialogue
Message.show("Hello there! Welcome to the world of POKéMON!", { gfxId = 71 })
assert_eq(Message._colors.fg, FrlgFont.STDPAL[8], "Message with Oak gfxId gets BLUE text")
-- Sign dialogue
Message.show("TRAINER TIPS", { gfxId = 103 })
assert_eq(Message._colors.fg, FrlgFont.STDPAL[2], "Message with Sign gets DARK_GRAY text")
-- Battle message
Message.show("A wild PIDGEY appeared!", { frame = "battle" })
assert_eq(Message._colors.fg, FrlgFont.STDPAL[1], "Battle message gets WHITE text")
Message.close()
print("[ok] Message.show color resolution verified")
print("\nALL TEXT COLORS & ROM PARITY TESTS PASSED CLEANLY!")