mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-29 23:14:53 -04:00
feat(game3): battle animation VM rendering architecture, full ROM extraction, evolution scene, and UI refinements
This commit is contained in:
@@ -1 +1 @@
|
||||
{"schema_version":1,"mods":[]}
|
||||
{"mods":[],"schema_version":1}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+781
-144
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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! ===")
|
||||
@@ -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%)")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
Reference in New Issue
Block a user