From b06d8bc99cc4227dad6cc3518bf75c94a153ee3f Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 10 Sep 2026 20:12:29 -0500 Subject: [PATCH] feat(game3): battle animation VM rendering architecture, full ROM extraction, evolution scene, and UI refinements --- mod_option_schemas.json | 2 +- src/core/game3/audio.lua | 4 +- src/core/game3/battle/anim.lua | 8 +- src/core/game3/battle/anim_callbacks.lua | 756 +++++++++++++- src/core/game3/battle/anim_sprites.lua | 92 +- src/core/game3/battle/anim_tasks.lua | 214 ++++ src/core/game3/battle/anim_vm.lua | 174 ++- src/core/game3/battle/engine.lua | 15 +- src/core/game3/battle/evo_seq.lua | 117 ++- src/core/game3/battle/healthbox.lua | 19 +- src/core/game3/battle/init.lua | 112 +- src/core/game3/battle/learn_move.lua | 113 +- src/core/game3/battle/residuals.lua | 132 ++- src/core/game3/battle/ui.lua | 46 +- src/core/game3/evolution.lua | 115 +- src/core/game3/gfx.lua | 7 +- src/core/game3/item_use.lua | 167 ++- src/core/game3/pokemon.lua | 65 +- src/core/game3/scripting/adapters.lua | 28 + src/core/game3/scripting/text_ir.lua | 21 +- src/core/game3/summary_data.lua | 23 +- src/import/gba/battle_anim_extract.lua | 1163 ++++++++++++--------- src/import/gba/cli_extract.lua | 26 +- src/import/gba/extract_audio.lua | 2 +- src/import/gba/extract_island1.lua | 65 +- src/import/gba/party_chrome_extract.lua | 140 ++- src/import/gba/pokemon_extract.lua | 24 +- src/import/gba/summary_chrome_extract.lua | 1 + src/import/gba/versions.lua | 479 ++++++++- src/ui/game3/battle_chrome.lua | 33 + src/ui/game3/evolution_scene.lua | 547 ++++++++++ src/ui/game3/frlg_font.lua | 453 +++++++- src/ui/game3/hud.lua | 19 +- src/ui/game3/message.lua | 22 +- src/ui/game3/party_chrome.lua | 140 ++- src/ui/game3/party_menu.lua | 925 +++++++++++++--- src/ui/game3/summary_chrome.lua | 17 +- src/ui/game3/summary_menu.lua | 185 +++- tests/game3_battle_anim_pack_test.lua | 108 ++ tests/game3_battle_rendering_test.lua | 80 ++ tests/game3_battle_status_timing_test.lua | 272 +++++ tests/game3_evolution_scene_test.lua | 279 +++++ tests/game3_item_use_party_test.lua | 509 +++++++++ tests/game3_text_colors_test.lua | 178 ++++ 44 files changed, 6686 insertions(+), 1211 deletions(-) create mode 100644 src/ui/game3/evolution_scene.lua create mode 100644 tests/game3_battle_anim_pack_test.lua create mode 100644 tests/game3_battle_rendering_test.lua create mode 100644 tests/game3_battle_status_timing_test.lua create mode 100644 tests/game3_evolution_scene_test.lua create mode 100644 tests/game3_item_use_party_test.lua create mode 100644 tests/game3_text_colors_test.lua diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 4002f364..592ffeba 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"schema_version":1,"mods":[]} \ No newline at end of file +{"mods":[],"schema_version":1} \ No newline at end of file diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 4e9be892..3af94aaa 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -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 diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 1f76c71c..3e0a31f1 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -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 diff --git a/src/core/game3/battle/anim_callbacks.lua b/src/core/game3/battle/anim_callbacks.lua index c8981f53..475fcbf9 100644 --- a/src/core/game3/battle/anim_callbacks.lua +++ b/src/core/game3/battle/anim_callbacks.lua @@ -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 + diff --git a/src/core/game3/battle/anim_sprites.lua b/src/core/game3/battle/anim_sprites.lua index d39ab8a1..070cf15b 100644 --- a/src/core/game3/battle/anim_sprites.lua +++ b/src/core/game3/battle/anim_sprites.lua @@ -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 diff --git a/src/core/game3/battle/anim_tasks.lua b/src/core/game3/battle/anim_tasks.lua index dcd5f28d..24c43874 100644 --- a/src/core/game3/battle/anim_tasks.lua +++ b/src/core/game3/battle/anim_tasks.lua @@ -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") diff --git a/src/core/game3/battle/anim_vm.lua b/src/core/game3/battle/anim_vm.lua index cac439f1..42873e02 100644 --- a/src/core/game3/battle/anim_vm.lua +++ b/src/core/game3/battle/anim_vm.lua @@ -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 diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index cf95d786..a75fb4da 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -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 diff --git a/src/core/game3/battle/evo_seq.lua b/src/core/game3/battle/evo_seq.lua index bc9f2c0b..c29d120a 100644 --- a/src/core/game3/battle/evo_seq.lua +++ b/src/core/game3/battle/evo_seq.lua @@ -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 diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index f3b37eba..aaf0ac82 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -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 diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 6ad81356..e51a342a 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -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 diff --git a/src/core/game3/battle/learn_move.lua b/src/core/game3/battle/learn_move.lua index f51a8bc5..06d63f72 100644 --- a/src/core/game3/battle/learn_move.lua +++ b/src/core/game3/battle/learn_move.lua @@ -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 = {} diff --git a/src/core/game3/battle/residuals.lua b/src/core/game3/battle/residuals.lua index 605774c1..1b0d2fe7 100644 --- a/src/core/game3/battle/residuals.lua +++ b/src/core/game3/battle/residuals.lua @@ -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 diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 93b4ba52..50af25cb 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -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) diff --git a/src/core/game3/evolution.lua b/src/core/game3/evolution.lua index 08541b18..2bbd23ad 100644 --- a/src/core/game3/evolution.lua +++ b/src/core/game3/evolution.lua @@ -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, diff --git a/src/core/game3/gfx.lua b/src/core/game3/gfx.lua index 59ac7d70..1b90fb19 100644 --- a/src/core/game3/gfx.lua +++ b/src/core/game3/gfx.lua @@ -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 diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index 5db4a2b6..65f871cb 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -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 diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index 001a35c4..cfc1a8d2 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -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 diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index a60504fb..ddf3daf1 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -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() diff --git a/src/core/game3/scripting/text_ir.lua b/src/core/game3/scripting/text_ir.lua index 706031ec..5b71d6cf 100644 --- a/src/core/game3/scripting/text_ir.lua +++ b/src/core/game3/scripting/text_ir.lua @@ -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 diff --git a/src/core/game3/summary_data.lua b/src/core/game3/summary_data.lua index b8d5bfb3..b24b8bcb 100644 --- a/src/core/game3/summary_data.lua +++ b/src/core/game3/summary_data.lua @@ -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 diff --git a/src/import/gba/battle_anim_extract.lua b/src/import/gba/battle_anim_extract.lua index ce849727..52df229d 100644 --- a/src/import/gba/battle_anim_extract.lua +++ b/src/import/gba/battle_anim_extract.lua @@ -1,344 +1,588 @@ --- Transpile pret battle_anim_scripts.s → portable IR pack + optional tag sheets. --- Pointers in createsprite/createvisualtask become named template/task IDs. --- Writes: data/generated/gba/pokemon/battle_anims/pack.lua (+ tags/*.idx.bin, *.pal) +-- ROM-native GBA battle anim extraction. +-- Decodes bytecode scripts from gBattleAnims_Moves and subroutines. +-- createsprite: reads SpriteTemplate.tileTag from the embedded ROM pointer → true tag. +-- Sprite sheets: decompressed from gBattleAnimPicTable + gBattleAnimPaletteTable → PNG. +-- Writes: {cacheRoot}/pokemon/battle_anims/pack.lua + tags/*.png -local Versions = require("src.import.gba.versions") -local AnimTemplates = require("src.core.game3.battle.anim_templates") +local Versions = require("src.import.gba.versions") +local Lz77 = require("src.import.gba.lz77") local BattleAnimExtract = {} -BattleAnimExtract.FORMAT_VERSION = 2 -BattleAnimExtract.CACHE_SUB = "pokemon/battle_anims" +BattleAnimExtract.FORMAT_VERSION = 3 +BattleAnimExtract.CACHE_SUB = "pokemon/battle_anims" -local function default_cache_root() - local ok, Extract = pcall(require, "src.import.gba.extract_island1") - if ok and Extract and Extract.CACHE_ROOT then - return Extract.CACHE_ROOT +local V = Versions.BATTLE_ANIMS or {} + +-- ── opcode sizes (total bytes incl. opcode byte) for fixed-width opcodes ── +-- Variable-width: 0x02 createsprite, 0x03 createvisualtask, 0x1F createsoundtask +local OP_FIXED = { + [0x00]=3,[0x01]=3, -- loadspritegfx / unloadspritegfx + [0x04]=2,[0x05]=1,[0x06]=1,[0x07]=1,[0x08]=1, -- delay/waitforvisualfinish/nop/nop2/end + [0x09]=3, -- playse + [0x0A]=2,[0x0B]=2, -- monbg / clearmonbg + [0x0C]=3, -- setalpha + [0x0D]=1, -- blendoff + [0x0E]=5, -- call + [0x0F]=1, -- return + [0x10]=4, -- setarg + [0x11]=9, -- choosetwoturnanim + [0x12]=6, -- jumpifmoveturn + [0x13]=5, -- goto + [0x14]=2,[0x15]=1,[0x16]=1,[0x17]=1,[0x18]=2, -- fadetobg/restorebg/waitbgfadeout/waitbgfadein/changebg + [0x19]=4, -- playsewithpan + [0x1A]=2, -- setpan + [0x1B]=8, -- panse + [0x1C]=6, -- loopsewithpan + [0x1D]=5, -- waitplaysewithpan + [0x1E]=3, -- setbldcnt + [0x20]=1, -- waitsound + [0x21]=8, -- jumpargeq + [0x22]=2,[0x23]=2, -- monbg_static / clearmonbg_static + [0x24]=5, -- jumpifcontest + [0x25]=4, -- fadetobgfromset + [0x26]=8,[0x27]=8, -- panse_adjustnone / panse_adjustall + [0x28]=2,[0x29]=1,[0x2A]=2, -- splitbgprio / splitbgprio_all / splitbgprio_foes + [0x2B]=2,[0x2C]=2, -- invisible / visible + [0x2D]=2,[0x2E]=2,[0x2F]=1, -- teamattack_moveback / movefwd / stopsound +} + +local SPRITES_START = V.sprites_start or 10000 +local TAG_NAMES = Versions.ANIM_TAG_NAMES or {} + +local BATTLER_NAMES = { [0]="attacker", [1]="target", [2]="atk_partner", [3]="def_partner" } + +-- Signed-extend 8-bit (for pan values stored as s8). +local function s8(v) + if v >= 128 then return v - 256 end + return v +end + +-- Signed-extend 16-bit (for arg values stored as s16). +local function s16(v) + if v >= 32768 then return v - 65536 end + return v +end + +--- Decode one battle anim bytecode script beginning at file offset `startOff`. +-- @param rom Rom instance +-- @param startOff 0-based ROM file offset of first opcode byte +-- @param visited table of offsets already decoded (avoid infinite loops across goto chains) +-- @param labels table: ROM-offset-string → script IR list (filled in-place for call/goto targets) +-- @return list of IR op tables +local function decode_script(rom, startOff, visited, labels, tag_dims) + if visited[startOff] then + return labels[tostring(startOff)] or {} end - return "data/generated/gba" -end + visited[startOff] = true -local function pret_scripts_path() - local env = os.getenv("POKEFIRERED") or os.getenv("POKEFIRE_RED") - if env and #env > 0 then - return env .. "/data/battle_anim_scripts.s" - end - local candidates = { - "../pokefirered/data/battle_anim_scripts.s", - "pokefirered/data/battle_anim_scripts.s", - } - for _, p in ipairs(candidates) do - local f = io.open(p, "rb") - if f then f:close() return p end - end - return nil -end - -local function read_file(path) - local f = io.open(path, "rb") - if not f then return nil end - local data = f:read("*a") - f:close() - return data -end - -local function split_args(s) - local out = {} - if not s or s == "" then return out end - for part in (s .. ","):gmatch("([^,]*),") do - part = part:match("^%s*(.-)%s*$") or part - if part ~= "" then - local num = tonumber(part) - if num then - out[#out + 1] = num - elseif part:match("^ANIM_") then - local token = part:gsub("^ANIM_", ""):lower() - if token == "attacker" or token == "target" - or token == "atk_partner" or token == "def_partner" then - out[#out + 1] = token - else - out[#out + 1] = part - end - else - out[#out + 1] = part - end - end - end - return out -end - -local function pret_sprites_dir() - local env = os.getenv("POKEFIRERED") or os.getenv("POKEFIRE_RED") - local bases = {} - if env and #env > 0 then bases[#bases + 1] = env end - bases[#bases + 1] = "../pokefirered" - bases[#bases + 1] = "pokefirered" - for _, b in ipairs(bases) do - local p = b .. "/graphics/battle_anims/sprites" - local f = io.open(p .. "/impact.png", "rb") - if f then f:close() return p end - end - return nil -end - -local function parse_tag(name) - name = tostring(name or ""):gsub("^ANIM_TAG_", "") - return name -end - ---- Parse one script body (lines) into IR ops. -local function parse_ops(lines) local ops = {} - local lastTag = nil - for _, line in ipairs(lines) do - line = line:gsub("%s+@.*$", ""):gsub("^%s+", ""):gsub("%s+$", "") - if line == "" or line:sub(1, 1) == "@" or line:sub(1, 1) == "." then - -- skip - elseif line:match("^loadspritegfx%s+") then - local tag = line:match("^loadspritegfx%s+(%S+)") - lastTag = parse_tag(tag) - ops[#ops + 1] = { op = "loadspritegfx", tag = lastTag } - elseif line:match("^unloadspritegfx%s+") then - local tag = line:match("^unloadspritegfx%s+(%S+)") - ops[#ops + 1] = { op = "unloadspritegfx", tag = parse_tag(tag) } - elseif line:match("^createsprite%s+") then - local rest = line:match("^createsprite%s+(.+)$") - local args = split_args(rest) - local template = tostring(args[1] or "Unknown") - local animBattler = args[2] or "attacker" - if type(animBattler) == "string" then - animBattler = animBattler:gsub("^anim_", "") - end - local subpri = tonumber(args[3]) or 2 - local sprArgs = {} - for i = 4, #args do sprArgs[#sprArgs + 1] = args[i] end - local info = AnimTemplates.get(template) - local tag = nil - if not (info and info.noGfx) then - tag = AnimTemplates.tagFromLoadOrTemplate(lastTag, template) - end - ops[#ops + 1] = { - op = "createsprite", - template = template, - animBattler = animBattler, - subpriority = subpri, - args = sprArgs, - tag = tag, - callback = info and info.callback or nil, - w = info and info.w or 32, - h = info and info.h or 32, - noGfx = info and info.noGfx or nil, - } - elseif line:match("^createvisualtask%s+") then - local rest = line:match("^createvisualtask%s+(.+)$") - local args = split_args(rest) - local task = tostring(args[1] or "stub") - local pri = tonumber(args[2]) or 2 - local targs = {} - for i = 3, #args do targs[#targs + 1] = args[i] end - ops[#ops + 1] = { - op = "createvisualtask", - task = task, - priority = pri, - args = targs, - } - elseif line:match("^delay%s+") then - local n = tonumber(line:match("^delay%s+(%S+)")) or 1 - ops[#ops + 1] = { op = "delay", frames = n } - elseif line == "waitforvisualfinish" then - ops[#ops + 1] = { op = "waitforvisualfinish" } - elseif line == "end" then + labels[tostring(startOff)] = ops + + local i = startOff + local guard = 0 + while guard < 512 do + guard = guard + 1 + if i >= rom.size then break end + + local op = rom:get(i) + + if op == 0x08 then -- end ops[#ops + 1] = { op = "end" } - elseif line == "blendoff" then + break + + elseif op == 0x00 then -- loadspritegfx + local tag_id = rom:u16(i + 1) + local tag_idx = tag_id - SPRITES_START + local name = TAG_NAMES[tag_idx] or ("TAG_" .. tag_idx) + ops[#ops + 1] = { op = "loadspritegfx", tag = name, tag_idx = tag_idx } + i = i + 3 + + elseif op == 0x01 then -- unloadspritegfx + local tag_id = rom:u16(i + 1) + local tag_idx = tag_id - SPRITES_START + local name = TAG_NAMES[tag_idx] or ("TAG_" .. tag_idx) + ops[#ops + 1] = { op = "unloadspritegfx", tag = name } + i = i + 3 + + elseif op == 0x02 then -- createsprite + local tmpl_gba = rom:u32(i + 1) + local tmpl_off = rom:ptrOffset(tmpl_gba) + -- SpriteTemplate layout: tileTag(u16)+paletteTag(u16)+oam*(u32)+anims*(u32)+images*(u32)+affineAnims*(u32)+callback*(u32) = 24 bytes + local tile_id = tmpl_off and rom:u16(tmpl_off) or 0 + local tile_idx = tile_id - SPRITES_START + local tag_name = (tile_id >= SPRITES_START) and (TAG_NAMES[tile_idx] or ("TAG_" .. tile_idx)) or nil + local noGfx = (tile_id < SPRITES_START) -- tileTag = 0 means TAG_NONE → no gfx + + -- OAM dimensions from SpriteTemplate.oam pointer + local w, h = 32, 32 + if tmpl_off then + local oam_gba = rom:u32(tmpl_off + 4) + local oam_off = rom:ptrOffset(oam_gba) + if oam_off then + -- OAM struct: 4 bytes total. shape=bits[15:14] of u16[0], size=bits[15:14] of u16[1] + local h0 = rom:u16(oam_off) + local h1 = rom:u16(oam_off + 2) + local shape = math.floor(h0 / 0x4000) % 4 -- bits [15:14] + local size = math.floor(h1 / 0x4000) % 4 -- bits [15:14] + local DIMS = { -- {w,h} indexed by [shape][size] + [0]={{8,8},{16,16},{32,32},{64,64}}, -- square + [1]={{16,8},{32,8},{32,16},{64,32}}, -- wide + [2]={{8,16},{8,32},{16,32},{32,64}}, -- tall + } + local row = DIMS[shape] + if row and row[size + 1] then + w, h = row[size + 1][1], row[size + 1][2] + end + end + end + + local cb_gba = tmpl_off and rom:u32(tmpl_off + 20) or 0 + local cb_name = Versions.ANIM_CALLBACK_NAMES and Versions.ANIM_CALLBACK_NAMES[cb_gba] + + if tag_name and tag_dims and not tag_dims[tag_name] then + tag_dims[tag_name] = { w = w, h = h } + end + + local battler_byte = rom:get(i + 5) + local is_target = (battler_byte >= 0x80) + local subpri = battler_byte % 0x80 + local argc = rom:get(i + 6) + local args = {} + for ai = 0, argc - 1 do + args[ai + 1] = s16(rom:u16(i + 7 + ai * 2)) + end + + local battler = is_target and "target" or "attacker" + + ops[#ops + 1] = { + op = "createsprite", + tag = tag_name, + callback = cb_name, + noGfx = noGfx or nil, + w = w, + h = h, + animBattler = battler, + subpriority = subpri, + args = args, + } + i = i + 7 + argc * 2 + + elseif op == 0x03 then -- createvisualtask + local fn_gba = rom:u32(i + 1) + local pri = rom:get(i + 5) + local argc = rom:get(i + 6) + local args = {} + for ai = 0, argc - 1 do + args[ai + 1] = s16(rom:u16(i + 7 + ai * 2)) + end + local task_name = Versions.ANIM_TASK_NAMES and Versions.ANIM_TASK_NAMES[fn_gba] + ops[#ops + 1] = { + op = "createvisualtask", + task = task_name or string.format("0x%08X", fn_gba), + priority = pri, + args = args, + } + i = i + 7 + argc * 2 + + elseif op == 0x04 then -- delay + ops[#ops + 1] = { op = "delay", frames = rom:get(i + 1) } + i = i + 2 + + elseif op == 0x05 then -- waitforvisualfinish + ops[#ops + 1] = { op = "waitforvisualfinish" } + i = i + 1 + + elseif op == 0x09 then -- playse + local se = rom:u16(i + 1) + ops[#ops + 1] = { op = "playse", se = se } + i = i + 3 + + elseif op == 0x0A then -- monbg + local b = rom:get(i + 1) + ops[#ops + 1] = { op = "monbg", battler = BATTLER_NAMES[b] or "target" } + i = i + 2 + + elseif op == 0x0B then -- clearmonbg + local b = rom:get(i + 1) + ops[#ops + 1] = { op = "clearmonbg", battler = BATTLER_NAMES[b] or "target" } + i = i + 2 + + elseif op == 0x0C then -- setalpha + local v = rom:u16(i + 1) + local eva = v % 256 + local evb = math.floor(v / 256) % 256 + ops[#ops + 1] = { op = "setalpha", eva = eva, evb = evb } + i = i + 3 + + elseif op == 0x0D then -- blendoff ops[#ops + 1] = { op = "blendoff" } - elseif line:match("^monbg%s+") then - local b = line:match("^monbg%s+(%S+)") - ops[#ops + 1] = { op = "monbg", battler = (b or "target"):gsub("^ANIM_", ""):lower() } - elseif line:match("^clearmonbg%s+") then - local b = line:match("^clearmonbg%s+(%S+)") - ops[#ops + 1] = { op = "clearmonbg", battler = (b or "target"):gsub("^ANIM_", ""):lower() } - elseif line:match("^setalpha%s+") then - local a, b = line:match("^setalpha%s+(%d+)%s*,%s*(%d+)") - ops[#ops + 1] = { op = "setalpha", eva = tonumber(a) or 12, evb = tonumber(b) or 8 } - elseif line:match("^playsewithpan%s+") or line:match("^playse%s+") then - -- pret macros: playsewithpan SE_M_FOO, SOUND_PAN_TARGET - local se, pan = line:match("playse[%w]*%s+([^,%s]+)%s*,%s*(%S+)") - if not se then - se = line:match("playse[%w]*%s+(%S+)") + i = i + 1 + + elseif op == 0x0E then -- call + local target_gba = rom:u32(i + 1) + local target_off = rom:ptrOffset(target_gba) + ops[#ops + 1] = { op = "call", label = tostring(target_off) } + i = i + 5 + -- Eagerly decode target if not yet visited + if target_off and not visited[target_off] then + decode_script(rom, target_off, visited, labels, tag_dims) end - if se then - se = se:gsub(",$", "") - local op = { op = "playse", se = se } - if pan then - -- Parens: gsub returns (str, count); tonumber(str, count) errors (base). - local panClean = (pan:gsub(",$", "")) - op.pan = tonumber(panClean) or panClean - end - ops[#ops + 1] = op - end - elseif line:match("^loopsewithpan%s+") then - -- pret: loopsewithpan SE, pan, framesBetween, playCount - local se, pan, wait, plays = line:match( - "loopsewithpan%s+([^,%s]+)%s*,%s*([^,%s]+)%s*,%s*(%d+)%s*,%s*(%d+)") - if not se then - se, pan = line:match("loopsewithpan%s+([^,%s]+)%s*,%s*(%S+)") - end - local op = { op = "loopsewithpan" } - if se then - op.se = (se:gsub(",$", "")) - if pan then - local panClean = (pan:gsub(",$", "")) - op.pan = tonumber(panClean) or panClean - end - op.wait = tonumber(wait) or 10 - op.plays = tonumber(plays) or 1 - end - ops[#ops + 1] = op - elseif line:match("^waitplaysewithpan%s+") then - -- pret: waitplaysewithpan SE, pan, frames - local se, pan, wait = line:match( - "waitplaysewithpan%s+([^,%s]+)%s*,%s*([^,%s]+)%s*,%s*(%d+)") - local op = { op = "waitplaysewithpan" } - if se then - op.se = (se:gsub(",$", "")) - if pan then - local panClean = (pan:gsub(",$", "")) - op.pan = tonumber(panClean) or panClean - end - op.wait = tonumber(wait) or 0 - end - ops[#ops + 1] = op - elseif line:match("^call%s+") then - local lab = line:match("^call%s+(%S+)") - ops[#ops + 1] = { op = "call", label = lab } - elseif line == "return" then + + elseif op == 0x0F then -- return ops[#ops + 1] = { op = "return" } - elseif line:match("^goto%s+") then - local lab = line:match("^goto%s+(%S+)") - ops[#ops + 1] = { op = "goto", label = lab } - elseif line:match("^invisible%s+") then - local b = line:match("^invisible%s+(%S+)") - ops[#ops + 1] = { op = "invisible", battler = (b or "attacker"):gsub("^ANIM_", ""):lower() } - elseif line:match("^visible%s+") then - local b = line:match("^visible%s+(%S+)") - ops[#ops + 1] = { op = "visible", battler = (b or "attacker"):gsub("^ANIM_", ""):lower() } - elseif line == "nop" or line == "nop2" then - ops[#ops + 1] = { op = line } + break + + elseif op == 0x10 then -- setarg + local argId = rom:get(i + 1) + local val = s16(rom:u16(i + 2)) + ops[#ops + 1] = { op = "setarg", argId = argId, value = val } + i = i + 4 + + elseif op == 0x13 then -- goto + local target_gba = rom:u32(i + 1) + local target_off = rom:ptrOffset(target_gba) + ops[#ops + 1] = { op = "goto", label = tostring(target_off) } + -- Decode target then stop (tail-jump) + if target_off and not visited[target_off] then + decode_script(rom, target_off, visited, labels, tag_dims) + end + break + + elseif op == 0x19 then -- playsewithpan + local se = rom:u16(i + 1) + local pan = s8(rom:get(i + 3)) + ops[#ops + 1] = { op = "playsewithpan", se = se, pan = pan } + i = i + 4 + + elseif op == 0x1C then -- loopsewithpan + local se = rom:u16(i + 1) + local pan = s8(rom:get(i + 3)) + local wait = rom:get(i + 4) + local times = rom:get(i + 5) + ops[#ops + 1] = { op = "loopsewithpan", se = se, pan = pan, wait = wait, times = times } + i = i + 6 + + elseif op == 0x1D then -- waitplaysewithpan + local se = rom:u16(i + 1) + local pan = s8(rom:get(i + 3)) + local wait = rom:get(i + 4) + ops[#ops + 1] = { op = "waitplaysewithpan", se = se, pan = pan, wait = wait } + i = i + 5 + + elseif op == 0x1F then -- createsoundtask (variable, like createvisualtask) + local fn_gba = rom:u32(i + 1) + local argc = rom:get(i + 5) + local args = {} + for ai = 0, argc - 1 do + args[ai + 1] = s16(rom:u16(i + 6 + ai * 2)) + end + ops[#ops + 1] = { op = "nop" } -- treat as nop; sound handled by SE ops + i = i + 6 + argc * 2 + + elseif op == 0x2B then -- invisible + ops[#ops + 1] = { op = "invisible", battler = BATTLER_NAMES[rom:get(i+1)] or "attacker" } + i = i + 2 + + elseif op == 0x2C then -- visible + ops[#ops + 1] = { op = "visible", battler = BATTLER_NAMES[rom:get(i+1)] or "attacker" } + i = i + 2 + + else + -- Fixed-size or unknown: step by known size, emit nop + local sz = OP_FIXED[op] or 1 + if sz > 1 or (op >= 0x05 and op <= 0x2F) then + ops[#ops + 1] = { op = "nop" } + end + i = i + sz end end - if #ops == 0 or ops[#ops].op ~= "end" then - if not (#ops > 0 and ops[#ops].op == "return") then - ops[#ops + 1] = { op = "end" } - end + + -- Ensure scripts always terminate + if #ops == 0 or (ops[#ops].op ~= "end" and ops[#ops].op ~= "return") then + ops[#ops + 1] = { op = "end" } end return ops end -local function parse_scripts_file(src) - local moves = {} - local status = {} - local general = {} - local special = {} - local labels = {} - local moveOrder = {} -- from gBattleAnims_Moves table - local inMovesTable = false +-- ── Sprite sheet extraction ─────────────────────────────────────────────── - -- Collect gBattleAnims_Moves pointer order - for line in (src .. "\n"):gmatch("(.-)\n") do - local trimmed = line:match("^%s*(.-)%s*$") or line - if trimmed:find("gBattleAnims_Moves::") then - inMovesTable = true - elseif inMovesTable then - if trimmed:match("^gBattleAnims_") or trimmed:match("^Move_") then - if not trimmed:match("^%.4byte") then - inMovesTable = false - end - end - local sym = trimmed:match("^%.4byte%s+(%S+)") - if sym then - moveOrder[#moveOrder + 1] = sym - end - end +--- Decode one GBA 4bpp tile (32 bytes) to 8×8 RGBA pixels into `out[1..64*4]`. +local function decode_4bpp_tile(rom_bytes, tile_off, pal, out, out_off) + for byte_i = 0, 31 do + local b = rom_bytes[tile_off + byte_i + 1] or 0 + local lo = b % 16 + local hi = math.floor(b / 16) % 16 + local px = out_off + byte_i * 8 + -- low nibble = left pixel (even x), high nibble = right pixel (odd x) + local cl = pal[lo] + local ch = pal[hi] + out[px + 1] = cl[1]; out[px + 2] = cl[2]; out[px + 3] = cl[3]; out[px + 4] = cl[4] + out[px + 5] = ch[1]; out[px + 6] = ch[2]; out[px + 7] = ch[3]; out[px + 8] = ch[4] end - - -- Split into labeled blocks - local current = nil - local buf = {} - local function flush() - if not current then return end - local ops = parse_ops(buf) - labels[current] = ops - if current:match("^Move_") then - moves[current] = ops - elseif current:match("^Status_") or current:match("^gBattleAnims_Status") then - status[current] = ops - elseif current:match("^General_") then - general[current] = ops - elseif current:match("^Special_") then - special[current] = ops - end - buf = {} - end - - for line in (src .. "\n"):gmatch("(.-)\n") do - local label = line:match("^([A-Za-z0-9_]+):") - if label and not line:match("^gBattleAnims_") then - flush() - current = label - elseif current then - buf[#buf + 1] = line - end - end - flush() - - -- Index moves by move id (table order; index 0 = MOVE_NONE) - local byId = {} - for i, sym in ipairs(moveOrder) do - local id = i - 1 - byId[id] = labels[sym] or moves[sym] - end - - return { - version = BattleAnimExtract.FORMAT_VERSION, - moves = byId, - status = status, - general = general, - special = special, - labels = labels, - moveSymbols = moveOrder, - tags = {}, - } end -local function serialize_value(v, indent) +--- Decode GBA RGB555 palette bytes (32 bytes = 16 colors) to RGBA table. +-- Color 0 is forced fully transparent (GBA OBJ convention). +local function decode_palette(pal_bytes) + local pal = {} + for ci = 0, 15 do + local v = pal_bytes[ci * 2 + 1] + pal_bytes[ci * 2 + 2] * 256 + local r = (v % 32) * 8 + local g = math.floor(v / 32) % 32 * 8 + local b = math.floor(v / 1024) % 32 * 8 + local a = (ci == 0) and 0 or 255 + pal[ci] = { r, g, b, a } + end + return pal +end + +local bit = require("bit") +local band, bor, bxor, rshift, lshift = bit.band, bit.bor, bit.bxor, bit.rshift, bit.lshift + +-- CRC-32 table for PNG chunk checksums +local crc_table = {} +for n = 0, 255 do + local c = n + for _ = 0, 7 do + if band(c, 1) ~= 0 then + c = bxor(0xEDB88320, rshift(c, 1)) + else + c = rshift(c, 1) + end + end + crc_table[n] = c +end + +local function crc32(str) + local c = 0xFFFFFFFF + for i = 1, #str do + local b = str:byte(i) + c = bxor(crc_table[band(bxor(c, b), 0xFF)], rshift(c, 8)) + end + return bxor(c, 0xFFFFFFFF) +end + +local function adler32(str) + local s1 = 1 + local s2 = 0 + for i = 1, #str do + s1 = (s1 + str:byte(i)) % 65521 + s2 = (s2 + s1) % 65521 + end + return s2 * 65536 + s1 +end + +local function u32be(n) + n = band(n, 0xFFFFFFFF) + return string.char( + band(rshift(n, 24), 0xFF), + band(rshift(n, 16), 0xFF), + band(rshift(n, 8), 0xFF), + band(n, 0xFF) + ) +end + +local function u16le(n) + return string.char(band(n, 0xFF), band(rshift(n, 8), 0xFF)) +end + +local function make_chunk(type_str, data) + local crc = crc32(type_str .. data) + return u32be(#data) .. type_str .. data .. u32be(crc) +end + +--- Encode RGBA pixel array to PNG bytes (uses love.image if available, else pure Lua PNG writer). +local function encode_png(pixels, w, h) + if love and love.image and love.graphics then + local ok, id = pcall(love.image.newImageData, w, h) + if ok and id then + id:mapPixel(function(x, y) + local base = (y * w + x) * 4 + 1 + return pixels[base] / 255, pixels[base + 1] / 255, + pixels[base + 2] / 255, pixels[base + 3] / 255 + end) + local ok2, fd = pcall(id.encode, id, "png") + if ok2 and fd then return fd:getString() end + end + end + + -- Pure Lua PNG writer + local raw_lines = {} + for y = 0, h - 1 do + local row = { string.char(0) } -- Filter type 0: None + local start_idx = y * w * 4 + 1 + for x = 0, w - 1 do + local idx = start_idx + x * 4 + row[#row + 1] = string.char(pixels[idx] or 0, pixels[idx+1] or 0, pixels[idx+2] or 0, pixels[idx+3] or 0) + end + raw_lines[#raw_lines + 1] = table.concat(row) + end + local raw_data = table.concat(raw_lines) + + -- Deflate uncompressed blocks (max 65535 per block) + local zlib_blocks = { string.char(0x78, 0x01) } -- ZLIB header + local pos = 1 + local total_len = #raw_data + while pos <= total_len do + local chunk_len = math.min(total_len - pos + 1, 65535) + local is_final = (pos + chunk_len > total_len) and 1 or 0 + zlib_blocks[#zlib_blocks + 1] = string.char(is_final) + zlib_blocks[#zlib_blocks + 1] = u16le(chunk_len) + zlib_blocks[#zlib_blocks + 1] = u16le(bxor(chunk_len, 0xFFFF)) + zlib_blocks[#zlib_blocks + 1] = raw_data:sub(pos, pos + chunk_len - 1) + pos = pos + chunk_len + end + zlib_blocks[#zlib_blocks + 1] = u32be(adler32(raw_data)) + local idat_data = table.concat(zlib_blocks) + + -- PNG Signature + IHDR + IDAT + IEND + local sig = "\137PNG\r\n\026\n" + local ihdr = u32be(w) .. u32be(h) .. string.char(8, 6, 0, 0, 0) + return sig .. make_chunk("IHDR", ihdr) .. make_chunk("IDAT", idat_data) .. make_chunk("IEND", "") +end + +--- Extract all sprite sheets referenced in `usedTags` from gBattleAnimPicTable. +-- @param rom Rom instance +-- @param cache cachefs with :write(rel, bytes) +-- @param root cache root prefix (e.g. "data/generated/gba") +-- @param usedTags table of tag_name → true (only extract what's actually used) +-- @param tag_dims optional table of tag_name → { w=N, h=N } from SpriteTemplates +-- @return tags metadata table: { TAG_NAME = { file=..., w=N, h=N, frameW=..., frameH=... }, ... } +local function extract_tag_sheets(rom, cache, root, usedTags, tag_dims) + local anim = Versions.BATTLE_ANIMS + local tags = {} + if not (anim and anim.pic_table) then return tags end + + local pic_base = anim.pic_table + local pal_base = anim.pal_table + + for idx = 0, anim.tag_count - 1 do + local name = TAG_NAMES[idx] + if not name then goto continue_tag end + if not usedTags[name] then goto continue_tag end + + -- Read pic pointer (8-byte stride: ptr at +0, size at +4) + local pic_gba = rom:u32(pic_base + idx * 8) + local pic_off = rom:ptrOffset(pic_gba) + if not (pic_off and rom:get(pic_off) == 0x10) then goto continue_tag end + + -- Read pal pointer + local pal_gba = rom:u32(pal_base + idx * 8) + local pal_off = rom:ptrOffset(pal_gba) + if not pal_off then goto continue_tag end + + -- Decompress tile data + local ok_lz, tile_bytes, _ = pcall(Lz77.decompress, function(j) return rom:get(j) end, pic_off) + if not ok_lz or not tile_bytes then goto continue_tag end + + -- Decode palette (32 bytes uncompressed, or LZ77 compressed) + local pal_bytes + if rom:get(pal_off) == 0x10 then + local ok_p, pb = pcall(Lz77.decompress, function(j) return rom:get(j) end, pal_off) + if ok_p and pb then pal_bytes = pb end + else + pal_bytes = {} + for pi = 0, 31 do pal_bytes[pi + 1] = rom:get(pal_off + pi) end + end + if not pal_bytes then goto continue_tag end + + local pal = decode_palette(pal_bytes) + + -- tile_bytes: raw 4bpp tile data. Each tile = 32 bytes = 8×8 pixels. + -- Tiles are arranged in columns determined by frame width. + local total_bytes = #tile_bytes + local tile_count = math.floor(total_bytes / 32) + if tile_count <= 0 then goto continue_tag end + + local frame_w = tag_dims and tag_dims[name] and tag_dims[name].w + local frame_h = tag_dims and tag_dims[name] and tag_dims[name].h + local best_w = frame_w and math.max(1, math.floor(frame_w / 8)) + if not best_w then + local widths = {1, 2, 4, 8, 16} + best_w = 4 + for _, w in ipairs(widths) do + if w * w <= tile_count then best_w = w end + end + end + + local img_w = best_w * 8 + local img_h = math.ceil(tile_count / best_w) * 8 + if img_h == 0 then img_h = img_w end + + -- Decode all tiles to RGBA + local pixels = {} + for pi = 1, img_w * img_h * 4 do pixels[pi] = 0 end + + local tiles_wide = math.floor(img_w / 8) + for ti = 0, tile_count - 1 do + local tx = ti % tiles_wide + local ty = math.floor(ti / tiles_wide) + -- Each tile's 8 rows map to img_w-stride RGBA pixels + for row = 0, 7 do + local byte_base = ti * 32 + row * 4 + for col = 0, 3 do + local b = tile_bytes[byte_base + col + 1] or 0 + local lo = b % 16 + local hi = math.floor(b / 16) % 16 + local px = ((ty * 8 + row) * img_w + tx * 8 + col * 2) + local cl = pal[lo] + local ch = pal[hi] + local base = px * 4 + 1 + pixels[base] = cl[1]; pixels[base+1] = cl[2] + pixels[base+2] = cl[3]; pixels[base+3] = cl[4] + base = base + 4 + pixels[base] = ch[1]; pixels[base+1] = ch[2] + pixels[base+2] = ch[3]; pixels[base+3] = ch[4] + end + end + end + + local png = encode_png(pixels, img_w, img_h) + if png and #png > 0 then + local rel = root .. "/tags/" .. name .. ".png" + if cache and cache.write then + cache:write(rel, png) + end + tags[name] = { + file = "tags/" .. name .. ".png", + w = img_w, + h = img_h, + frameW = frame_w or (best_w * 8), + frameH = frame_h or (best_w * 8), + } + end + + ::continue_tag:: + end + return tags +end + +-- ── Lua serializer ──────────────────────────────────────────────────────── + +local function serialize(v, indent) indent = indent or "" local t = type(v) - if t == "nil" then return "nil" end + if t == "nil" then return "nil" end if t == "boolean" then return v and "true" or "false" end - if t == "number" then return tostring(v) end - if t == "string" then return string.format("%q", v) end + if t == "number" then return tostring(v) end + if t == "string" then return string.format("%q", v) end if t == "table" then - -- array? - local n = #v - local isArray = true - local count = 0 + local n, isArr, count = #v, true, 0 for k in pairs(v) do count = count + 1 - if type(k) ~= "number" or k < 1 or k > n or k % 1 ~= 0 then - isArray = false - end + if type(k) ~= "number" or k < 1 or k > n or k % 1 ~= 0 then isArr = false end end - if isArray and n > 0 then - local parts = { "{" } + if isArr and n > 0 then + local parts = {"{"} for i = 1, n do - parts[#parts + 1] = "\n" .. indent .. " " .. serialize_value(v[i], indent .. " ") .. "," + parts[#parts+1] = "\n"..indent.." "..serialize(v[i], indent.." ")..(i < n and "," or "") end - parts[#parts + 1] = "\n" .. indent .. "}" + parts[#parts+1] = "\n"..indent.."}" return table.concat(parts) end - local parts = { "{" } - local keys = {} - for k in pairs(v) do keys[#keys + 1] = k end + local keys, parts = {}, {"{"} + for k in pairs(v) do keys[#keys+1] = k end table.sort(keys, function(a, b) local ta, tb = type(a), type(b) if ta == tb then @@ -348,230 +592,143 @@ local function serialize_value(v, indent) return ta < tb end) for _, k in ipairs(keys) do - local key - if type(k) == "string" and k:match("^[%a_][%w_]*$") then - key = k - else - key = "[" .. serialize_value(k) .. "]" - end - parts[#parts + 1] = "\n" .. indent .. " " .. key .. " = " .. serialize_value(v[k], indent .. " ") .. "," + local key = (type(k)=="string" and k:match("^[%a_][%w_]*$")) and k or ("["..serialize(k).."]") + parts[#parts+1] = "\n"..indent.." "..key.." = "..serialize(v[k], indent.." ")..(k ~= keys[#keys] and "," or "") end - parts[#parts + 1] = "\n" .. indent .. "}" + parts[#parts+1] = "\n"..indent.."}" return table.concat(parts) end return "nil" end ---- Copy pret battle_anims/sprites/*.png into cache as RGBA PNGs with --- palette index 0 punched to transparent (GBA OBJ convention). -local function extract_tag_pngs(cache, root, spritesDir) - local tags = {} - if not spritesDir then return tags end - local stems = {} - local p = io.popen('ls -1 "' .. spritesDir .. '"/*.png 2>/dev/null') - if p then - for line in p:lines() do - local stem = line:match("([^/]+)%.png$") - if stem then stems[#stems + 1] = stem end - end - p:close() - end - - local tmpDir = os.getenv("TMPDIR") or "/tmp" - local listPath = tmpDir .. "/gba_anim_tags_list.txt" - local outDir = tmpDir .. "/gba_anim_tags_out" - os.execute('mkdir -p "' .. outDir .. '"') - do - local lf = io.open(listPath, "wb") - if lf then - for _, stem in ipairs(stems) do - lf:write(spritesDir .. "/" .. stem .. ".png\n") - end - lf:close() - end - end - - -- Convert indexed PNGs → RGBA with GBA index-0 transparent (+ magenta key punch). - local py = [[ -import sys -from PIL import Image -list_path, out_dir = sys.argv[1], sys.argv[2] -n = 0 -with open(list_path) as f: - paths = [ln.strip() for ln in f if ln.strip()] - -def is_key(r, g, b): - return (r, g, b) == (98, 41, 255) or (r > 80 and r < 120 and g < 60 and b > 240) - -for src in paths: - try: - im = Image.open(src) - except Exception: - continue - stem = src.rsplit('/', 1)[-1].rsplit('.', 1)[0] - if im.mode == 'P': - # GBA OBJ: palette index 0 is transparent regardless of color. - im.info['transparency'] = 0 - im = im.convert('RGBA') - else: - im = im.convert('RGBA') - px = im.load() - w, h = im.size - for y in range(h): - for x in range(w): - r, g, b, a = px[x, y] - if a == 0: - continue - if is_key(r, g, b): - px[x, y] = (0, 0, 0, 0) - out = out_dir + '/' + stem.upper() + '.png' - im.save(out, 'PNG') - n += 1 -print(n) -]] - local pyPath = tmpDir .. "/gba_anim_tags_convert.py" - do - local pf = io.open(pyPath, "wb") - if pf then pf:write(py) pf:close() end - end - local converted = 0 - local pipe = io.popen('python3 "' .. pyPath .. '" "' .. listPath .. '" "' .. outDir .. '" 2>/dev/null') - if pipe then - local line = pipe:read("*l") - pipe:close() - converted = tonumber(line) or 0 - end - - local copied = 0 - if converted > 0 then - local lp = io.popen('ls -1 "' .. outDir .. '"/*.png 2>/dev/null') - if lp then - for line in lp:lines() do - local tag = line:match("([^/]+)%.png$") - if tag then - local f = io.open(line, "rb") - if f then - local bytes = f:read("*a") - f:close() - local rel = root .. "/tags/" .. tag .. ".png" - if cache and cache.write and bytes then - cache:write(rel, bytes) - tags[tag] = { - file = "tags/" .. tag .. ".png", - format = "png_rgba", - transparent = "index0", - } - copied = copied + 1 - end - end - end - end - lp:close() - end - else - -- Fallback: raw copy (runtime will punch magenta) - for _, stem in ipairs(stems) do - local srcPath = spritesDir .. "/" .. stem .. ".png" - local f = io.open(srcPath, "rb") - if f then - local bytes = f:read("*a") - f:close() - local tag = stem:upper() - local rel = root .. "/tags/" .. tag .. ".png" - if cache and cache.write and bytes then - cache:write(rel, bytes) - tags[tag] = { file = "tags/" .. tag .. ".png", format = "png", stem = stem } - copied = copied + 1 - end - end - end - end - print("[battle_anim_extract] wrote", copied, "tag sheets (transparent index0) from", spritesDir) - return tags -end +-- ── Generic fallback script (used when no pack is available) ────────────── local GENERIC = { - { op = "loadspritegfx", tag = "IMPACT" }, + { op = "loadspritegfx", tag = "IMPACT", tag_idx = 135 }, { op = "monbg", battler = "target" }, - { op = "createsprite", template = "gHorizontalLungeSpriteTemplate", animBattler = "attacker", subpriority = 2, args = { 4, 4 }, noGfx = true, callback = "HorizontalLunge" }, - { op = "delay", frames = 6 }, - { op = "createsprite", template = "gBasicHitSplatSpriteTemplate", animBattler = "attacker", subpriority = 2, tag = "IMPACT", callback = "HitSplatBasic", w = 32, h = 32, args = { 0, 0, "target", 2 } }, - { op = "createvisualtask", task = "AnimTask_ShakeMon", priority = 2, args = { "target", 3, 0, 6, 1 } }, + { op = "createsprite", tag = "IMPACT", noGfx = nil, w = 32, h = 32, + animBattler = "attacker", subpriority = 2, + args = { 0, 0, -1, 2 } }, -- args[3]=-1 → from HorizontalLunge noGfx template + { op = "createsprite", tag = "IMPACT", w = 32, h = 32, + animBattler = "attacker", subpriority = 2, args = { 0, 0, 1, 2 } }, + { op = "createvisualtask", task = "AnimTask_ShakeMon", priority = 2, + args = { 1, 3, 0, 6, 1 } }, { op = "waitforvisualfinish" }, { op = "clearmonbg", battler = "target" }, + { op = "blendoff" }, { op = "end" }, } -function BattleAnimExtract.ready(cache, root) - root = root or BattleAnimExtract.CACHE_SUB +-- ── Public API ──────────────────────────────────────────────────────────── + +function BattleAnimExtract.ready(cache, cacheRoot) + local root = (cacheRoot or "data/generated/gba") .. "/" .. BattleAnimExtract.CACHE_SUB local path = root .. "/pack.lua" - if cache and cache.exists then - return cache:exists(path) - end - local f = io.open(path, "rb") or io.open("data/generated/gba/" .. path, "rb") - if f then f:close() return true end + if cache and cache.exists and cache:exists(path) then return true end + if cache and cache.read and cache:read(path) then return true end return false end -function BattleAnimExtract.run(opts) - opts = opts or {} - local cache = opts.cache - local root = BattleAnimExtract.CACHE_SUB +--- Main extraction entry point. +-- @param rom Rom instance (required for ROM-native extraction) +-- @param cache cache object with :write(rel, bytes) method +-- @param opts { force=bool, cacheRoot=string, progressCb=function } +function BattleAnimExtract.run(rom, cache, opts) + opts = opts or {} + local root = (opts.cacheRoot or "data/generated/gba") .. "/" .. BattleAnimExtract.CACHE_SUB - -- Fast path: if already extracted and not forcing re-extract, skip - if not opts.force and BattleAnimExtract.ready(cache, root) then + -- Skip if already extracted and not forced + if not opts.force and BattleAnimExtract.ready(cache, opts.cacheRoot or "data/generated/gba") then return { - path = root .. "/pack.lua", - moveCount = Versions.MOVES_COUNT or 355, - tagCount = 0, - version = BattleAnimExtract.FORMAT_VERSION, - skipped = true, + path = root .. "/pack.lua", + moveCount = V.move_count or 355, + tagCount = 0, + version = BattleAnimExtract.FORMAT_VERSION, + skipped = true, } end - local scriptsPath = opts.scriptsPath or pret_scripts_path() - local pack - - if scriptsPath then - local src = read_file(scriptsPath) - if src then - pack = parse_scripts_file(src) - print("[battle_anim_extract] parsed", scriptsPath, "moves", pack.moveSymbols and #pack.moveSymbols or 0) - end - end - - if not pack then - pack = { - version = BattleAnimExtract.FORMAT_VERSION, - moves = {}, - status = {}, - general = {}, - special = {}, - labels = {}, - tags = {}, + local anim = Versions.BATTLE_ANIMS + if not (rom and anim and anim.moves_table) then + -- No ROM — write generic fallback + local pack = { + version = BattleAnimExtract.FORMAT_VERSION, + moves = {}, + labels = {}, + tags = {}, } - for id = 0, (Versions.MOVES_COUNT or 355) do + for id = 0, (V.move_count or 355) - 1 do pack.moves[id] = GENERIC end - print("[battle_anim_extract] no pret scripts; wrote generic IR for all moves") - else - for id = 0, (Versions.MOVES_COUNT or 355) do - if not pack.moves[id] then - pack.moves[id] = GENERIC + local lua = "return " .. serialize(pack) .. "\n" + if cache and cache.write then cache:write(root .. "/pack.lua", lua) end + print("[battle_anim_extract] no ROM — wrote generic fallback") + return { path = root .. "/pack.lua", moveCount = 0, tagCount = 0, + version = BattleAnimExtract.FORMAT_VERSION } + end + + -- ── Step 1: Decode all move scripts ────────────────────────────────── + local visited = {} + local labels = {} -- offset-string → IR ops list + local moves = {} -- [id] → IR ops list + local usedTags = {} -- tag_name → true + local tagDims = {} -- tag_name → { w=N, h=N } + + local moves_table = anim.moves_table + local move_count = anim.move_count or 355 + + print("[battle_anim_extract] decoding " .. move_count .. " move scripts from ROM...") + for id = 0, move_count - 1 do + local gba_ptr = rom:u32(moves_table + id * 4) + local off = rom:ptrOffset(gba_ptr) + if off then + local script = decode_script(rom, off, visited, labels, tagDims) + moves[id] = script + -- Collect used tags + for _, op in ipairs(script) do + if op.tag and op.tag ~= "" then + usedTags[op.tag] = true + end end + else + moves[id] = GENERIC end end - pack.version = BattleAnimExtract.FORMAT_VERSION - pack.tags = extract_tag_pngs(cache, root, pret_sprites_dir()) + -- Collect tags from all label scripts too + for _, script in pairs(labels) do + for _, op in ipairs(script) do + if op.tag and op.tag ~= "" then usedTags[op.tag] = true end + end + end - -- Strip label bodies' trailing `end` if they also `return` (call/return) - -- Already handled in parse_ops for return without forcing end onto return-only... + local tag_count = 0 + for _ in pairs(usedTags) do tag_count = tag_count + 1 end + print(string.format("[battle_anim_extract] %d moves decoded, %d unique tags", move_count, tag_count)) - local lua = "return " .. serialize_value(pack) .. "\n" - local outRel = root .. "/pack.lua" + -- ── Step 2: Extract sprite sheets ─────────────────────────────────── + local tagMeta = {} + if cache then + print("[battle_anim_extract] extracting " .. tag_count .. " sprite sheets...") + tagMeta = extract_tag_sheets(rom, { + write = function(_, rel, bytes) return cache:write(rel, bytes) end, + }, root, usedTags, tagDims) + local extracted = 0 + for _ in pairs(tagMeta) do extracted = extracted + 1 end + print(string.format("[battle_anim_extract] wrote %d tag PNGs", extracted)) + end + + -- ── Step 3: Serialize pack ────────────────────────────────────────── + local pack = { + version = BattleAnimExtract.FORMAT_VERSION, + moves = moves, + labels = labels, + tags = tagMeta, + } + + local lua = "return " .. serialize(pack) .. "\n" if cache and cache.write then - cache:write(outRel, lua) + cache:write(root .. "/pack.lua", lua) elseif opts.outPath then local f = assert(io.open(opts.outPath, "wb")) f:write(lua) @@ -579,10 +736,10 @@ function BattleAnimExtract.run(opts) end return { - path = outRel, - moveCount = pack.moveSymbols and #pack.moveSymbols or (Versions.MOVES_COUNT or 355), - tagCount = 0, - version = BattleAnimExtract.FORMAT_VERSION, + path = root .. "/pack.lua", + moveCount = move_count, + tagCount = tag_count, + version = BattleAnimExtract.FORMAT_VERSION, } end diff --git a/src/import/gba/cli_extract.lua b/src/import/gba/cli_extract.lua index 6c8adb2c..1ee9e6d4 100644 --- a/src/import/gba/cli_extract.lua +++ b/src/import/gba/cli_extract.lua @@ -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) diff --git a/src/import/gba/extract_audio.lua b/src/import/gba/extract_audio.lua index 71b7220b..1d90736f 100644 --- a/src/import/gba/extract_audio.lua +++ b/src/import/gba/extract_audio.lua @@ -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, } diff --git a/src/import/gba/extract_island1.lua b/src/import/gba/extract_island1.lua index 22e036d9..b6e2d5cd 100644 --- a/src/import/gba/extract_island1.lua +++ b/src/import/gba/extract_island1.lua @@ -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") diff --git a/src/import/gba/party_chrome_extract.lua b/src/import/gba/party_chrome_extract.lua index 7b9da567..4f32f284 100644 --- a/src/import/gba/party_chrome_extract.lua +++ b/src/import/gba/party_chrome_extract.lua @@ -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) diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index 1408d577..87249c7b 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -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 diff --git a/src/import/gba/summary_chrome_extract.lua b/src/import/gba/summary_chrome_extract.lua index 84e72998..1ce2da3f 100644 --- a/src/import/gba/summary_chrome_extract.lua +++ b/src/import/gba/summary_chrome_extract.lua @@ -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) diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index b5e41bf0..77afccd0 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -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. diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index ffdf1ba6..7ab31eb6 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -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) diff --git a/src/ui/game3/evolution_scene.lua b/src/ui/game3/evolution_scene.lua new file mode 100644 index 00000000..aba5c4e7 --- /dev/null +++ b/src/ui/game3/evolution_scene.lua @@ -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 diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index b614e8a2..d11041e5 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -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 diff --git a/src/ui/game3/hud.lua b/src/ui/game3/hud.lua index 2da7d582..b00222e1 100644 --- a/src/ui/game3/hud.lua +++ b/src/ui/game3/hud.lua @@ -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 diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index d254abdf..4f93ad6f 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -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 diff --git a/src/ui/game3/party_chrome.lua b/src/ui/game3/party_chrome.lua index 9f382350..b461f01a 100644 --- a/src/ui/game3/party_chrome.lua +++ b/src/ui/game3/party_chrome.lua @@ -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 diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index b343505a..0367df18 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -34,6 +34,63 @@ local function se(id) pcall(function() require("src.core.game3.audio").playSe(id) end) end +local function nav_up(cur, n) + if cur == 1 then + return 7 + elseif cur == 7 then + return n + else + return cur - 1 + end +end + +local function nav_down(cur, n) + if cur == 7 then + return 1 + elseif cur == n then + return 7 + else + return cur + 1 + end +end + +local function nav_left(cur, n, lastSlot) + if cur ~= 1 and cur ~= 7 then + return 1, cur + end + return cur, lastSlot +end + +local function nav_right(cur, n, lastSlot) + if cur == 1 and n > 1 then + local target = lastSlot or 2 + if target < 2 then target = 2 end + if target > n then target = n end + return target, lastSlot + end + return cur, lastSlot +end + +local function get_mon_stats(mon) + return { + maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1, + atk = tonumber(mon and (mon.attack or mon.atk)) or 1, + def = tonumber(mon and (mon.defense or mon.def)) or 1, + spa = tonumber(mon and (mon.spAtk or mon.spa or mon.spatk)) or 1, + spd = tonumber(mon and (mon.spDef or mon.spd or mon.spdef)) or 1, + spe = tonumber(mon and (mon.speed or mon.spe)) or 1, + } +end + +function PartyMenu.showStatGrowth(mon, oldStats, newStats, onDone) + PartyMenu._statGrowthMon = mon + PartyMenu._statGrowthOld = oldStats + PartyMenu._statGrowthNew = newStats + PartyMenu._statGrowthPage = 1 + PartyMenu._statGrowthDone = onDone + PartyMenu.mode = "stat_growth" +end + -- pret sSinglePartyMenuWindowTemplate local SLOT_WIN = { { left = 1, top = 3, w = 10, h = 7, kind = "main" }, @@ -105,6 +162,34 @@ local SUB_STATUS = 0 local SUB_BALL = 4 local SUB_MON = 8 +local MON_ICON_ANIM_DELAYS = { + [0] = 6, -- HP_BAR_FULL (100% HP) + [1] = 8, -- HP_BAR_GREEN (>50% HP) + [2] = 14, -- HP_BAR_YELLOW (>20% HP) + [3] = 22, -- HP_BAR_RED (>0% HP) + [4] = 0, -- HP_BAR_EMPTY (0 HP / fainted: still) +} + +local MON_ICON_ANIM_DURATIONS = { + [0] = 6 / 60, -- HP_BAR_FULL (100% HP): 6 frames = 0.100s + [1] = 8 / 60, -- HP_BAR_GREEN (>50% HP): 8 frames = 0.1333s + [2] = 14 / 60, -- HP_BAR_YELLOW (>20% HP): 14 frames = 0.2333s + [3] = 22 / 60, -- HP_BAR_RED (>0% HP): 22 frames = 0.3667s + [4] = 0, -- HP_BAR_EMPTY (0 HP / fainted: still) +} + +local function get_hp_bar_level(hp, maxHp, isEgg) + if isEgg then return 4 end + hp = tonumber(hp) or 0 + maxHp = tonumber(maxHp) or 1 + if maxHp < 1 then maxHp = 1 end + if hp >= maxHp then return 0 end + if hp > maxHp * 0.5 then return 1 end + if hp > maxHp * 0.2 then return 2 end + if hp > 0 then return 3 end + return 4 +end + local function idle_mon_offset(slotIndex) local spr = SLOT_SPRITES[slotIndex] if spr and spr[1] == 16 then @@ -113,17 +198,61 @@ local function idle_mon_offset(slotIndex) return -4, 0 end -local function SpriteCB_BouncePartyMonIcon(sprite) - sprite.data[1] = (sprite.data[1] or 0) + 1 - if sprite.data[1] % 8 == 0 then - sprite.data[2] = 1 - (sprite.data[2] or 0) +local function advance_sprite_anim(sprite) + local animNum = sprite.data[3] or 0 + local duration = MON_ICON_ANIM_DURATIONS[animNum] or (8 / 60) + if duration <= 0 then + sprite.data[2] = 0 + return 0 end + + if love.timer and love.timer.getTime then + local now = love.timer.getTime() + local last = sprite._lastAnimTime or now + local dt = now - last + sprite._lastAnimTime = now + if dt < 0 then dt = 0 elseif dt > 0.1 then dt = 0.1 end + sprite._animElapsed = (sprite._animElapsed or 0) + dt + while sprite._animElapsed >= duration do + sprite._animElapsed = sprite._animElapsed - duration + sprite.data[2] = 1 - (sprite.data[2] or 0) + end + else + local delay = MON_ICON_ANIM_DELAYS[animNum] or 8 + sprite.data[1] = (sprite.data[1] or 0) + 1 + if sprite.data[1] >= delay then + sprite.data[1] = 0 + sprite.data[2] = 1 - (sprite.data[2] or 0) + end + end + + return sprite.data[2] or 0 +end + +local function SpriteCB_BouncePartyMonIcon(sprite) + local f = advance_sprite_anim(sprite) sprite.x2 = 0 - if (sprite.data[2] or 0) == 0 then + if (sprite.data[3] or 0) == 4 then + sprite.y2 = 0 + elseif f == 0 then sprite.y2 = -3 else sprite.y2 = 1 end + if sprite._quads and sprite._quads[f] then + sprite.quad = sprite._quads[f] + end +end + +local function SpriteCB_UpdatePartyMonIcon(sprite) + local f = advance_sprite_anim(sprite) + local slotIdx = sprite.data[4] or 1 + local x2, y2 = idle_mon_offset(slotIdx) + sprite.x2 = x2 + sprite.y2 = y2 + if sprite._quads and sprite._quads[f] then + sprite.quad = sprite._quads[f] + end end local function ensure_slot_sprites(i, mon, selected) @@ -145,34 +274,54 @@ local function ensure_slot_sprites(i, mon, selected) local bx, by = spr[7], spr[8] local sx, sy = spr[5], spr[6] + local hp = tonumber(mon.hp) or 0 + local maxHp = tonumber(mon.maxHp) or tonumber(mon.maxhp) or 1 + local hpLevel = get_hp_bar_level(hp, maxHp, mon.isEgg) + local icon = Pokemon.icon(Pokemon.speciesOf(mon)) + local q0 = icon and icon.quads and icon.quads[0] if not slot.mon then local id = select(1, Oam.createSprite({ dims = Oam.SQUARE_32, priority = 1, image = icon and icon.image, + quad = q0, animPaused = true, }, mx, my, SUB_MON)) slot.mon = id else Oam.setPos(slot.mon, mx, my) - if icon and icon.image then Oam.setImage(slot.mon, icon.image, nil) end local ms = Oam.get(slot.mon) - if ms then ms.subpriority = SUB_MON end + if ms then + ms.image = icon and icon.image + ms.subpriority = SUB_MON + end end if slot.mon then + local s = Oam.get(slot.mon) + if s then + s._quads = icon and icon.quads + s.data[3] = hpLevel + s.data[4] = i + end if selected then Oam.setCallback(slot.mon, SpriteCB_BouncePartyMonIcon) if not slot._wasSelected then - Oam.setOffset(slot.mon, 0, 0) - local s = Oam.get(slot.mon) - if s then s.data[1], s.data[2] = 0, 0 end + if s then + s.data[1] = 0 + s.x2, s.y2 = 0, ((s.data[2] or 0) == 1 and 1 or -3) + end end slot._wasSelected = true else - Oam.setCallback(slot.mon, nil) - local x2, y2 = idle_mon_offset(i) - Oam.setOffset(slot.mon, x2, y2) + Oam.setCallback(slot.mon, SpriteCB_UpdatePartyMonIcon) + if slot._wasSelected then + if s then + s.data[1] = 0 + local x2, y2 = idle_mon_offset(i) + s.x2, s.y2 = x2, y2 + end + end slot._wasSelected = false end Oam.setInvisible(slot.mon, PartyMenu.mode == "summary") @@ -261,6 +410,7 @@ function PartyMenu.show(sessionParty, moveOverlay, opts) PartyMenu._onMessageDismiss = nil PartyMenu.actionCursor = 1 PartyMenu.itemActionCursor = 1 + PartyMenu._lastSelectedSlot = 1 if not Pokemon._names then Pokemon.install(nil) end PartyChrome.install(nil) Stack.push("party", PartyMenu, { hideBelow = true }) @@ -335,6 +485,34 @@ function PartyMenu.showMessage(text, onDismiss) PartyMenu._onMessageDismiss = onDismiss end +PartyMenu._yesNoPrompt = nil +PartyMenu._yesNoCallback = nil +PartyMenu._yesNoCursor = 1 +PartyMenu._forgetPrompt = nil +PartyMenu._forgetMoves = nil +PartyMenu._forgetCallback = nil +PartyMenu._forgetCursor = 1 + +function PartyMenu.showYesNo(promptText, cb) + PartyMenu.mode = "yesno" + PartyMenu._yesNoPrompt = promptText + PartyMenu._yesNoCallback = cb + PartyMenu._yesNoCursor = 1 +end + +function PartyMenu.showForgetPrompt(promptText, moveNames, cb) + PartyMenu.mode = "forget" + PartyMenu._forgetPrompt = promptText + PartyMenu._forgetMoves = moveNames + PartyMenu._forgetCallback = cb + PartyMenu._forgetCursor = 1 +end + +function PartyMenu.reloadSprites() + destroy_party_oam() + sync_all_oam() +end + PartyMenu._hpAnim = nil function PartyMenu.startHpAnim(slot, startHp, targetHp, maxHp, onDone) @@ -350,17 +528,23 @@ end function PartyMenu.update(dt) local anim = PartyMenu._hpAnim - if not anim then return end - dt = dt or (1 / 60) - if anim.current < anim.target then - anim.current = math.min(anim.target, anim.current + anim.speed * dt) - elseif anim.current > anim.target then - anim.current = math.max(anim.target, anim.current - anim.speed * dt) + if anim then + dt = dt or (1 / 60) + if anim.current < anim.target then + anim.current = math.min(anim.target, anim.current + anim.speed * dt) + elseif anim.current > anim.target then + anim.current = math.max(anim.target, anim.current - anim.speed * dt) + end + if anim.current == anim.target then + local cb = anim.onDone + PartyMenu._hpAnim = nil + if cb then cb() end + end end - if anim.current == anim.target then - local cb = anim.onDone - PartyMenu._hpAnim = nil - if cb then cb() end + + local LearnMove = package.loaded["src.core.game3.battle.learn_move"] + if LearnMove and LearnMove.busy and LearnMove.busy() and LearnMove.pump then + LearnMove.pump() end end @@ -393,6 +577,57 @@ function PartyMenu.handleInput(input) return end + if PartyMenu.mode == "yesno" then + if input:wasPressed("up") or input:wasPressed("down") then + PartyMenu._yesNoCursor = (PartyMenu._yesNoCursor == 1) and 2 or 1 + se(5) + elseif input:wasPressed("a") then + se(5) + local cb = PartyMenu._yesNoCallback + local yes = (PartyMenu._yesNoCursor == 1) + PartyMenu._yesNoCallback = nil + PartyMenu._yesNoPrompt = nil + if cb then cb(yes) end + elseif input:wasPressed("b") then + se(9) + local cb = PartyMenu._yesNoCallback + PartyMenu._yesNoCallback = nil + PartyMenu._yesNoPrompt = nil + if cb then cb(false) end + end + return + end + + if PartyMenu.mode == "forget" then + local moves = PartyMenu._forgetMoves or {} + local total = #moves + if total > 0 then + if input:wasPressed("up") then + PartyMenu._forgetCursor = ((PartyMenu._forgetCursor - 2) % total) + 1 + se(5) + elseif input:wasPressed("down") then + PartyMenu._forgetCursor = (PartyMenu._forgetCursor % total) + 1 + se(5) + elseif input:wasPressed("a") then + se(5) + local cb = PartyMenu._forgetCallback + local idx = PartyMenu._forgetCursor + PartyMenu._forgetCallback = nil + PartyMenu._forgetMoves = nil + PartyMenu._forgetPrompt = nil + if cb then cb(idx - 1) end + elseif input:wasPressed("b") then + se(9) + local cb = PartyMenu._forgetCallback + PartyMenu._forgetCallback = nil + PartyMenu._forgetMoves = nil + PartyMenu._forgetPrompt = nil + if cb then cb(nil) end + end + end + return + end + if PartyMenu.mode == "summary" then if not SummaryMenu.isOpen() then destroy_party_oam() @@ -409,17 +644,30 @@ function PartyMenu.handleInput(input) end if PartyMenu.mode == "switch" then + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then - se(5) - swap_slots(PartyMenu.switchFrom or PartyMenu.cursor, PartyMenu.cursor) - PartyMenu.switchFrom = nil - PartyMenu.mode = "list" + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then + se(9) + PartyMenu.switchFrom = nil + PartyMenu.mode = "list" + else + se(5) + swap_slots(PartyMenu.switchFrom or PartyMenu.cursor, PartyMenu.cursor) + PartyMenu.switchFrom = nil + PartyMenu.mode = "list" + end elseif input:wasPressed("b") then se(9) PartyMenu.switchFrom = nil @@ -463,6 +711,22 @@ function PartyMenu.handleInput(input) return end + if PartyMenu.mode == "stat_growth" then + if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then + if PartyMenu._statGrowthPage == 1 then + se(5) + PartyMenu._statGrowthPage = 2 + else + se(5) + PartyMenu.mode = "message" + local cb = PartyMenu._statGrowthDone + PartyMenu._statGrowthDone = nil + if cb then cb() end + end + end + return + end + if PartyMenu.mode == "action" then local actions = PartyMenu.ACTIONS if input:wasPressed("up") then @@ -568,25 +832,37 @@ function PartyMenu.handleInput(input) end if PartyMenu.mode == "softboiled" then + local oldCur = PartyMenu.cursor local FieldMoves = require("src.core.game3.field_moves") if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then - local userMon = PartyMenu._party and PartyMenu._party[PartyMenu._softboiledDonorSlot] - local targetMon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - local ok, userHp, targetHp = FieldMoves.softboiledTransfer(userMon, targetMon) - if not ok then + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then se(9) - PartyMenu.showMessage("It won't have any effect.", function() - PartyMenu.mode = "softboiled" - end) - else - se(11) PartyMenu.mode = "list" + else + local userMon = PartyMenu._party and PartyMenu._party[PartyMenu._softboiledDonorSlot] + local targetMon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + local ok, userHp, targetHp = FieldMoves.softboiledTransfer(userMon, targetMon) + if not ok then + se(9) + PartyMenu.showMessage("It won't have any effect.", function() + PartyMenu.mode = "softboiled" + end) + else + se(11) + PartyMenu.mode = "list" + end end elseif input:wasPressed("b") then se(9) @@ -597,38 +873,50 @@ function PartyMenu.handleInput(input) -- Battle switch mode if PartyMenu.mode == "battle_switch" then + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then - local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - local activeSlot = PartyMenu._activeSlot or 1 - local hp = tonumber(mon and mon.hp) or 0 - local name = mon and Pokemon.displayName(mon) or "POKéMON" - if PartyMenu.cursor == activeSlot then + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then se(9) - PartyMenu.showMessage(name .. " is already in\nbattle!", function() - PartyMenu.mode = "battle_switch" - end) - elseif hp <= 0 then - se(9) - PartyMenu.showMessage("There's no will to\nfight!", function() - PartyMenu.mode = "battle_switch" - end) - elseif mon and mon.isEgg then - se(9) - PartyMenu.showMessage("An EGG can't battle!", function() - PartyMenu.mode = "battle_switch" - end) + PartyMenu.close() else - se(5) - PartyMenu.ACTIONS = { "SHIFT", "SUMMARY", "CANCEL" } - PartyMenu._previousMode = "battle_switch" - PartyMenu.mode = "action" - PartyMenu.actionCursor = 1 + local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + local activeSlot = PartyMenu._activeSlot or 1 + local hp = tonumber(mon and mon.hp) or 0 + local name = mon and Pokemon.displayName(mon) or "POKéMON" + if PartyMenu.cursor == activeSlot then + se(9) + PartyMenu.showMessage(name .. " is already in\nbattle!", function() + PartyMenu.mode = "battle_switch" + end) + elseif hp <= 0 then + se(9) + PartyMenu.showMessage("There's no will to\nfight!", function() + PartyMenu.mode = "battle_switch" + end) + elseif mon and mon.isEgg then + se(9) + PartyMenu.showMessage("An EGG can't battle!", function() + PartyMenu.mode = "battle_switch" + end) + else + se(5) + PartyMenu.ACTIONS = { "SHIFT", "SUMMARY", "CANCEL" } + PartyMenu._previousMode = "battle_switch" + PartyMenu.mode = "action" + PartyMenu.actionCursor = 1 + end end elseif input:wasPressed("b") or input:wasPressed("start") then se(9) @@ -683,53 +971,288 @@ function PartyMenu.handleInput(input) -- Selection mode for item USE if PartyMenu.mode == "use" then + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then + se(9) + PartyMenu.close() + return + end local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - if mon and mon.isEgg then + if not mon then return end + + if mon.isEgg then se(9) PartyMenu.showMessage("An EGG can't be used on.", function() PartyMenu.mode = "use" end) - else - local startHp = tonumber(mon and mon.hp) or 0 - local maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1 - local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, PartyMenu.cursor) - local endHp = tonumber(mon and mon.hp) or startHp - if ok then - se(2) - local hasRemaining = Bag.has(PartyMenu._bag, PartyMenu._item, 1) - local isSingleUse = ItemsData.isTm(PartyMenu._item) or ItemsData.isEvolutionStone(PartyMenu._item) or not hasRemaining - if endHp > startHp then - PartyMenu.startHpAnim(PartyMenu.cursor, startHp, endHp, maxHp, function() - PartyMenu.showMessage(msgText, function() - if isSingleUse then - PartyMenu.close() - else - PartyMenu.mode = "use" + return + end + + -- Case 1: TM / HM + if ItemsData.isTm(PartyMenu._item) then + local status, preflightMsg, moveId, moveName = ItemUse.checkTmPreflight(mon, PartyMenu._item) + if status == "knows" or status == "incompatible" or status == "invalid" then + se(9) + PartyMenu.showMessage(preflightMsg, function() + PartyMenu.mode = "use" + end) + elseif status == "ok" then + se(5) + PartyMenu.showYesNo(preflightMsg, function(yes) + if not yes then + PartyMenu.mode = "use" + return + end + local isHm = ItemsData.isHm(PartyMenu._item) + local monName = Pokemon.displayMonName(mon) + + if Pokemon.moveSlotCount(mon) < 4 then + local ok = Pokemon.teachMove(mon, moveId) + if ok then + if not isHm then + Bag.remove(PartyMenu._bag, PartyMenu._item, 1) end - end) - end) - else - PartyMenu.showMessage(msgText, function() - if isSingleUse then - PartyMenu.close() + pcall(function() require("src.core.game3.audio").playFanfare(257) end) + PartyMenu.showMessage(string.format("%s learned\n%s!", monName, moveName), function() + PartyMenu.close() + end) else PartyMenu.mode = "use" end - end) - end - else - se(9) - PartyMenu.showMessage(msgText or "It won't have any effect.", function() - PartyMenu.mode = "use" + else + -- Full moveset: Forget Move flow + local LearnMove = require("src.core.game3.battle.learn_move") + LearnMove.begin({ + mon = mon, + moveId = moveId, + displayName = monName, + pushMsg = function(t, cb) PartyMenu.showMessage(t, cb) end, + askYesNo = function(a, b) + local cb = (type(a) == "function") and a or b + local prompt = (type(a) == "string") and a or PartyMenu._messageText or "" + PartyMenu.showYesNo(prompt, function(yes) + if cb then cb(yes == true) end + end) + end, + askForget = function(a, b, c) + local cb = (type(b) == "function") and b or c + local SummaryMenu = require("src.ui.game3.summary_menu") + local LearnMove = require("src.core.game3.battle.learn_move") + local moveToLearn = (LearnMove and LearnMove._moveId) or moveId + destroy_party_oam() + SummaryMenu.openMenu(PartyMenu._party, PartyMenu.cursor, { + mode = "select_move", + moveToLearn = moveToLearn, + onSelectMove = function(slotIdx) + sync_all_oam() + if cb then cb(slotIdx) end + end, + }) + end, + onDone = function(learned) + if learned then + if not isHm then + Bag.remove(PartyMenu._bag, PartyMenu._item, 1) + end + pcall(function() require("src.core.game3.audio").playFanfare(257) end) + PartyMenu.close() + else + PartyMenu.showMessage(string.format("%s did not learn\n%s.", monName, moveName), function() + PartyMenu.mode = "use" + end) + end + end, + }) + end end) end + return + end + + -- Case 2: Rare Candy / Level up item + if ItemsData.fieldUseKind(PartyMenu._item) == "level" then + local lvl = tonumber(mon.level) or 1 + local hp = tonumber(mon.hp) or 0 + if lvl >= 100 or hp <= 0 then + se(9) + PartyMenu.showMessage("It won't have any effect.", function() + PartyMenu.mode = "use" + end) + return + end + + se(1) + local oldStats = get_mon_stats(mon) + local oldMax = oldStats.maxHp + local oldHp = hp + + Bag.remove(PartyMenu._bag, PartyMenu._item, 1) + mon.level = lvl + 1 + Pokemon.applyStats(mon) + local newStats = get_mon_stats(mon) + local newMax = newStats.maxHp + local newHp = math.min(newMax, oldHp + math.max(0, newMax - oldMax)) + mon.hp = newHp + + pcall(function() require("src.core.game3.audio").playFanfare(257) end) -- MUS_LEVEL_UP (257) + + local monName = Pokemon.displayMonName(mon) + local lvlMsg = string.format("%s was elevated to\nLv. %d.", monName, mon.level) + local slot = PartyMenu.cursor + + local function check_evolution() + local Evolution = require("src.core.game3.evolution") + local toSpecies = Evolution.levelTarget(mon, PartyMenu._session) + if toSpecies then + destroy_party_oam() + local EvolutionScene = require("src.ui.game3.evolution_scene") + local Audio = require("src.core.game3.audio") + EvolutionScene.start(mon, toSpecies, { + session = PartyMenu._session, + bag = PartyMenu._bag, + canStop = true, + savedSong = Audio._mapSong, + onDone = function(result) + PartyMenu.reloadSprites() + PartyMenu.mode = "list" + end, + }) + else + if Bag.has(PartyMenu._bag, PartyMenu._item, 1) then + PartyMenu.mode = "use" + else + PartyMenu.close() + end + end + end + + local function after_level_up() + local spId = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId) + local newMoves = Pokemon.movesLearnedAt(spId, mon.level) + if #newMoves > 0 then + local LearnMove = require("src.core.game3.battle.learn_move") + local started = LearnMove.beginQueue(mon, { mon.level }, { + displayName = monName, + pushMsg = function(t, cb) PartyMenu.showMessage(t, cb) end, + askYesNo = function(a, b) + local cb = (type(a) == "function") and a or b + local prompt = (type(a) == "string") and a or PartyMenu._messageText or "" + PartyMenu.showYesNo(prompt, function(yes) + if cb then cb(yes == true) end + end) + end, + askForget = function(a, b, c) + local cb = (type(b) == "function") and b or c + local SummaryMenu = require("src.ui.game3.summary_menu") + local LearnMove = require("src.core.game3.battle.learn_move") + destroy_party_oam() + SummaryMenu.openMenu(PartyMenu._party, PartyMenu.cursor, { + mode = "select_move", + moveToLearn = LearnMove._moveId, + onSelectMove = function(slotIdx) + sync_all_oam() + if cb then cb(slotIdx) end + end, + }) + end, + onDone = function() + check_evolution() + end, + }) + if not started then + check_evolution() + end + else + check_evolution() + end + end + + PartyMenu._messageText = lvlMsg + local function show_growth() + PartyMenu.showStatGrowth(mon, oldStats, newStats, after_level_up) + end + + if newHp > oldHp then + PartyMenu.startHpAnim(slot, oldHp, newHp, newMax, show_growth) + else + show_growth() + end + return + end + + -- Case 3: Evolution Stone + if ItemsData.isEvolutionStone(PartyMenu._item) then + local Evolution = require("src.core.game3.evolution") + local toSpecies = Evolution.itemTarget(mon, PartyMenu._item, PartyMenu._session) + if not toSpecies then + se(9) + PartyMenu.showMessage("It won't have any effect.", function() + PartyMenu.mode = "use" + end) + else + Bag.remove(PartyMenu._bag, PartyMenu._item, 1) + destroy_party_oam() + local EvolutionScene = require("src.ui.game3.evolution_scene") + local Audio = require("src.core.game3.audio") + EvolutionScene.start(mon, toSpecies, { + session = PartyMenu._session, + bag = PartyMenu._bag, + canStop = false, + savedSong = Audio._mapSong, + onDone = function(result) + PartyMenu.reloadSprites() + PartyMenu.mode = "list" + end, + }) + end + return + end + + -- Case 4: General Medicine / Potions / Status + local startHp = tonumber(mon and mon.hp) or 0 + local maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1 + local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, PartyMenu.cursor) + local endHp = tonumber(mon and mon.hp) or startHp + if ok then + se(2) + local hasRemaining = Bag.has(PartyMenu._bag, PartyMenu._item, 1) + if endHp > startHp then + PartyMenu.startHpAnim(PartyMenu.cursor, startHp, endHp, maxHp, function() + PartyMenu.showMessage(msgText, function() + if hasRemaining then + PartyMenu.mode = "use" + else + PartyMenu.close() + end + end) + end) + else + PartyMenu.showMessage(msgText, function() + if hasRemaining then + PartyMenu.mode = "use" + else + PartyMenu.close() + end + end) + end + else + se(9) + PartyMenu.showMessage(msgText or "It won't have any effect.", function() + PartyMenu.mode = "use" + end) end elseif input:wasPressed("b") or input:wasPressed("start") then se(9) @@ -740,25 +1263,37 @@ function PartyMenu.handleInput(input) -- Selection mode for item GIVE if PartyMenu.mode == "give" then + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then - local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - if mon and mon.isEgg then + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then se(9) - PartyMenu.showMessage("An EGG can't hold an item.", function() - PartyMenu.close() - end) + PartyMenu.close() else - local ok, reason, msgText = ItemUse.giveToMon(PartyMenu._session, PartyMenu._bag, PartyMenu._item, PartyMenu.cursor) - if ok then se(5) else se(9) end - PartyMenu.showMessage(msgText, function() - PartyMenu.close() - end) + local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + if mon and mon.isEgg then + se(9) + PartyMenu.showMessage("An EGG can't hold an item.", function() + PartyMenu.close() + end) + else + local ok, reason, msgText = ItemUse.giveToMon(PartyMenu._session, PartyMenu._bag, PartyMenu._item, PartyMenu.cursor) + if ok then se(5) else se(9) end + PartyMenu.showMessage(msgText, function() + PartyMenu.close() + end) + end end elseif input:wasPressed("b") or input:wasPressed("start") then se(9) @@ -769,17 +1304,29 @@ function PartyMenu.handleInput(input) -- Selection mode for generic choose if PartyMenu.mode == "choose" then + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then - se(5) - local cb = PartyMenu._onSelect - PartyMenu.close() - if cb then cb(PartyMenu.cursor, PartyMenu._party and PartyMenu._party[PartyMenu.cursor]) end + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then + se(9) + PartyMenu.close() + else + se(5) + local cb = PartyMenu._onSelect + PartyMenu.close() + if cb then cb(PartyMenu.cursor, PartyMenu._party and PartyMenu._party[PartyMenu.cursor]) end + end elseif input:wasPressed("b") or input:wasPressed("start") then se(9) PartyMenu.close() @@ -788,13 +1335,25 @@ function PartyMenu.handleInput(input) end -- Default list mode + local oldCur = PartyMenu.cursor if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + end + if PartyMenu.cursor ~= oldCur then se(5) - elseif input:wasPressed("a") then + end + if input:wasPressed("a") then + if PartyMenu.cursor == 7 then + se(9) + PartyMenu.close() + return + end se(5) local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] local FieldMoves = require("src.core.game3.field_moves") @@ -862,6 +1421,16 @@ local function draw_filled_slot(i, mon, selected) party_print(name, baseX + info.nick[1], baseY + info.nick[2], 40) party_print("Lv" .. tostring(mon.level or 0), baseX + info.level[1], baseY + info.level[2], 32) + local gender = mon.gender or (Pokemon.gender and Pokemon.gender(mon.species, mon.personality)) + local isNidoran = (mon.species == 29 or mon.species == 32) + if gender and not isNidoran then + if gender == "M" then + FrlgFont.draw("♂", baseX + info.gender[1], baseY + info.gender[2], { colors = FrlgFont.COLOR.PARTY_MALE, small = true }) + elseif gender == "F" then + FrlgFont.draw("♀", baseX + info.gender[1], baseY + info.gender[2], { colors = FrlgFont.COLOR.PARTY_FEMALE, small = true }) + end + end + local hp = tonumber(mon.hp) or 0 local maxHp = tonumber(mon.maxHp) or tonumber(mon.maxhp) or 0 local anim = PartyMenu._hpAnim @@ -905,14 +1474,78 @@ function PartyMenu.draw() local wrapped = FrlgFont.wrap(PartyMenu._messageText, 216) FrlgFont.draw(wrapped, 1 * 8 + 6, 15 * 8 + 4, { maxWidth = 216, linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) end + elseif PartyMenu.mode == "stat_growth" then + Window.stdFrame(Window.template(1, 15, 28, 4)) + if PartyMenu._messageText then + local wrapped = FrlgFont.wrap(PartyMenu._messageText, 216) + FrlgFont.draw(wrapped, 1 * 8 + 6, 15 * 8 + 4, { maxWidth = 216, linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) + end + + local winX, winY, winW, winH = 19, 1, 10, 11 + Window.stdFrame(Window.template(winX, winY, winW, winH)) + local statNames = { "MAX. HP", "ATTACK", "DEFENSE", "SP. ATK", "SP. DEF", "SPEED" } + local oldS = PartyMenu._statGrowthOld or {} + local newS = PartyMenu._statGrowthNew or {} + local oldList = { oldS.maxHp or 0, oldS.atk or 0, oldS.def or 0, oldS.spa or 0, oldS.spd or 0, oldS.spe or 0 } + local newList = { newS.maxHp or 0, newS.atk or 0, newS.def or 0, newS.spa or 0, newS.spd or 0, newS.spe or 0 } + local isPage1 = (PartyMenu._statGrowthPage == 1) + + for idx = 1, 6 do + local rowY = winY * 8 + 2 + (idx - 1) * 14 + FrlgFont.draw(statNames[idx], winX * 8 + 2, rowY, { colors = FrlgFont.COLOR.NORMAL }) + if isPage1 then + local diff = newList[idx] - oldList[idx] + local sign = (diff >= 0) and "+" or "-" + local diffStr = string.format("%s%2d", sign, math.abs(diff)) + FrlgFont.draw(diffStr, winX * 8 + 52, rowY, { colors = FrlgFont.COLOR.NORMAL }) + else + local valStr = string.format("%3d", newList[idx]) + FrlgFont.draw(valStr, winX * 8 + 52, rowY, { colors = FrlgFont.COLOR.NORMAL }) + end + end + elseif PartyMenu.mode == "yesno" then + Window.stdFrame(Window.template(1, 15, 28, 4)) + if PartyMenu._yesNoPrompt then + local wrapped = FrlgFont.wrap(PartyMenu._yesNoPrompt, 216) + FrlgFont.draw(wrapped, 1 * 8 + 6, 15 * 8 + 4, { maxWidth = 216, linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) + end + -- Yes/No window overlay at tile X=21, Y=9, W=6, H=4 + local ynX = 21 + local ynY = 9 + Window.stdFrame(Window.template(ynX, ynY, 6, 4)) + local options = { "YES", "NO" } + for i, opt in ipairs(options) do + local rowY = (ynY * 8) + (i - 1) * 16 + 2 + if i == PartyMenu._yesNoCursor then + Window.cursorPx(ynX * 8 + 1, rowY) + end + FrlgFont.draw(opt, ynX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) + end + elseif PartyMenu.mode == "forget" then + Window.stdFrame(Window.template(1, 17, 15, 2)) + FrlgFont.draw("Which move?", 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) + + local moves = PartyMenu._forgetMoves or {} + local popW = 11 + local popH = math.max(4, #moves * 2) + local popX = 18 + local popY = 19 - popH + Window.stdFrame(Window.template(popX, popY, popW, popH)) + for i, mv in ipairs(moves) do + local rowY = (popY * 8) + (i - 1) * 16 + 2 + if i == PartyMenu._forgetCursor then + Window.cursorPx(popX * 8 + 1, rowY) + end + FrlgFont.draw(tostring(mv), popX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) + end elseif PartyMenu.mode == "item_action" then - Window.stdFrame(Window.template(2, 15, 15, 4)) - FrlgFont.draw("Do what with an\nitem?", 2 * 8 + 4, 15 * 8 + 2, { linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) + Window.stdFrame(Window.template(1, 17, 19, 2)) + FrlgFont.draw("Do what with an\nitem?", 1 * 8 + 2, 17 * 8 + 1, { linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) local actCount = #PartyMenu.ITEM_ACTIONS - local popW = 8 + local popW = 7 local popH = actCount * 2 - local popX = 21 + local popX = 22 local popY = 19 - popH Window.stdFrame(Window.template(popX, popY, popW, popH)) for i, act in ipairs(PartyMenu.ITEM_ACTIONS) do @@ -923,8 +1556,8 @@ function PartyMenu.draw() FrlgFont.draw(act, popX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) end elseif PartyMenu.mode == "action" then - Window.stdFrame(Window.template(2, 15, 15, 4)) - FrlgFont.draw("Do what with this\nPOKéMON?", 2 * 8 + 4, 15 * 8 + 2, { linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) + Window.stdFrame(Window.template(1, 17, 16, 2)) + FrlgFont.draw("Do what with this\nPOKéMON?", 1 * 8 + 2, 17 * 8 + 1, { linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) local actCount = #PartyMenu.ACTIONS local popW = 10 @@ -938,24 +1571,28 @@ function PartyMenu.draw() Window.cursorPx(popX * 8 + 1, rowY) end local isFm = PartyMenu._fieldMoveNames and PartyMenu._fieldMoveNames[act] - local col = isFm and (FrlgFont.COLOR.BLUE or FrlgFont.COLOR.MALE) or FrlgFont.COLOR.NORMAL + local col = isFm and (FrlgFont.COLOR.BLUE or FrlgFont.COLOR.MALE_NPC) or FrlgFont.COLOR.NORMAL FrlgFont.draw(act, popX * 8 + 9, rowY, { colors = col }) end else - Window.stdFrame(Window.template(1, 15, 28, 4)) + Window.stdFrame(Window.template(1, 17, 21, 2)) + local promptText = "Choose a POKéMON." if PartyMenu.mode == "switch" then - FrlgFont.draw("Move to where?", 1 * 8 + 6, 15 * 8 + 4, { colors = FrlgFont.COLOR.NORMAL }) + promptText = "Move to where?" elseif PartyMenu.mode == "use" then if PartyMenu._item and ItemsData.isTm(PartyMenu._item) then - FrlgFont.draw("Teach which POKéMON?", 1 * 8 + 6, 15 * 8 + 4, { colors = FrlgFont.COLOR.NORMAL }) + promptText = "Teach which POKéMON?" else - FrlgFont.draw("Use on which POKéMON?", 1 * 8 + 6, 15 * 8 + 4, { colors = FrlgFont.COLOR.NORMAL }) + promptText = "Use on which POKéMON?" end elseif PartyMenu.mode == "give" then - FrlgFont.draw("Give to which POKéMON?", 1 * 8 + 6, 15 * 8 + 4, { colors = FrlgFont.COLOR.NORMAL }) - else - FrlgFont.draw("Choose a POKéMON.", 1 * 8 + 6, 15 * 8 + 4, { colors = FrlgFont.COLOR.NORMAL }) + promptText = "Give to which POKéMON?" end + FrlgFont.draw(promptText, 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) + end + + if PartyMenu.mode ~= "message" and PartyMenu.mode ~= "stat_growth" and PartyMenu.mode ~= "yesno" then + PartyChrome.drawCancelButton(184, 136, PartyMenu.cursor == 7) end end diff --git a/src/ui/game3/summary_chrome.lua b/src/ui/game3/summary_chrome.lua index e767c31b..d675ed96 100644 --- a/src/ui/game3/summary_chrome.lua +++ b/src/ui/game3/summary_chrome.lua @@ -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 diff --git a/src/ui/game3/summary_menu.lua b/src/ui/game3/summary_menu.lua index 619f10a8..ba31f8da 100644 --- a/src/ui/game3/summary_menu.lua +++ b/src/ui/game3/summary_menu.lua @@ -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() diff --git a/tests/game3_battle_anim_pack_test.lua b/tests/game3_battle_anim_pack_test.lua new file mode 100644 index 00000000..510b0ebc --- /dev/null +++ b/tests/game3_battle_anim_pack_test.lua @@ -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 diff --git a/tests/game3_battle_rendering_test.lua b/tests/game3_battle_rendering_test.lua new file mode 100644 index 00000000..5cf2fc15 --- /dev/null +++ b/tests/game3_battle_rendering_test.lua @@ -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! ===") diff --git a/tests/game3_battle_status_timing_test.lua b/tests/game3_battle_status_timing_test.lua new file mode 100644 index 00000000..fd5a120b --- /dev/null +++ b/tests/game3_battle_status_timing_test.lua @@ -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%)") diff --git a/tests/game3_evolution_scene_test.lua b/tests/game3_evolution_scene_test.lua new file mode 100644 index 00000000..b585a4ee --- /dev/null +++ b/tests/game3_evolution_scene_test.lua @@ -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 diff --git a/tests/game3_item_use_party_test.lua b/tests/game3_item_use_party_test.lua new file mode 100644 index 00000000..b70465e1 --- /dev/null +++ b/tests/game3_item_use_party_test.lua @@ -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 diff --git a/tests/game3_text_colors_test.lua b/tests/game3_text_colors_test.lua new file mode 100644 index 00000000..b4ecb5e5 --- /dev/null +++ b/tests/game3_text_colors_test.lua @@ -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!")