From b67fb745fa2b7cb0f878a78e4e975588b030ce0f Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 13 Sep 2026 20:07:53 -0500 Subject: [PATCH] feat(game3): battle anim engine parity, sub-containers, town map, and party menu fixes --- mobile/android/gradle.properties | 2 +- mod_option_schemas.json | 2 +- src/core/Data.lua | 26 +- src/core/game3/bag.lua | 156 +- src/core/game3/battle/anim.lua | 98 +- src/core/game3/battle/anim_callbacks.lua | 458 ++ src/core/game3/battle/anim_tasks.lua | 4558 ++++++++++++++++- src/core/game3/battle/anim_vm.lua | 204 +- src/core/game3/battle/learn_move.lua | 37 +- src/core/game3/battle/ui.lua | 29 +- src/core/game3/field.lua | 5 + src/core/game3/field_effects.lua | 29 + src/core/game3/item_use.lua | 25 +- src/core/game3/items_data.lua | 130 +- src/core/game3/m4a_mix.lua | 55 +- src/core/game3/m4a_player.lua | 53 +- src/core/game3/m4a_seq.lua | 112 +- src/core/game3/palette.lua | 7 +- src/core/game3/player.lua | 9 + src/core/game3/pokecenter_heal.lua | 7 +- src/core/game3/pokemon.lua | 5 + src/core/game3/runtime.lua | 54 +- src/core/game3/scripting/adapters.lua | 48 +- src/core/game3/scripting/trainers.lua | 11 +- src/core/game3/step_events.lua | 287 ++ src/core/game3/tileset_native.lua | 5 +- src/core/game3/vs_seeker.lua | 110 + src/import/gba/battle_anim_extract.lua | 72 +- src/import/gba/berry_pouch_extract.lua | 211 + src/import/gba/cli_extract.lua | 6 +- src/import/gba/extract_island1.lua | 22 + src/import/gba/pokemon_extract.lua | 10 + src/import/gba/region_map_extract.lua | 235 + src/import/gba/tm_case_extract.lua | 323 ++ src/import/gba/trainer_card_extract.lua | 14 +- src/import/gba/versions.lua | 35 +- src/ui/game3/bag_menu.lua | 56 +- src/ui/game3/berry_pouch.lua | 466 ++ src/ui/game3/berry_pouch_chrome.lua | 144 + src/ui/game3/box_storage_ui.lua | 15 +- src/ui/game3/frlg_font.lua | 7 + src/ui/game3/hall_of_fame.lua | 244 + src/ui/game3/naming.lua | 9 +- src/ui/game3/party_menu.lua | 15 +- src/ui/game3/pc_chrome.lua | 27 +- src/ui/game3/pokedex.lua | 14 +- src/ui/game3/region_map.lua | 315 +- src/ui/game3/release_seq.lua | 7 +- src/ui/game3/tm_case.lua | 433 ++ src/ui/game3/tm_case_chrome.lua | 199 + src/ui/game3/trainer_card.lua | 271 +- tests/game3_audio_test.lua | 60 +- tests/game3_bag_test.lua | 32 + tests/game3_battle_anims_coverage_test.lua | 161 + tests/game3_battle_anims_phase1_test.lua | 182 + tests/game3_battle_anims_phase2_test.lua | 384 ++ tests/game3_battle_anims_phase3_test.lua | 347 ++ tests/game3_battle_anims_phase4_test.lua | 399 ++ tests/game3_battle_anims_pret_parity_test.lua | 263 + tests/game3_berry_pouch_1to1_test.lua | 128 + tests/game3_evolution_scene_test.lua | 4 +- tests/game3_hall_of_fame_test.lua | 135 + tests/game3_item_use_party_test.lua | 106 + tests/game3_pokecenter_heal_test.lua | 14 + tests/game3_save_trainer_card_test.lua | 119 +- tests/game3_step_events_queue_test.lua | 264 + ...game3_tm_case_berry_pouch_extract_test.lua | 101 + tests/game3_town_map_test.lua | 111 + 68 files changed, 11946 insertions(+), 536 deletions(-) create mode 100644 src/core/game3/step_events.lua create mode 100644 src/core/game3/vs_seeker.lua create mode 100644 src/import/gba/berry_pouch_extract.lua create mode 100644 src/import/gba/region_map_extract.lua create mode 100644 src/import/gba/tm_case_extract.lua create mode 100644 src/ui/game3/berry_pouch.lua create mode 100644 src/ui/game3/berry_pouch_chrome.lua create mode 100644 src/ui/game3/hall_of_fame.lua create mode 100644 src/ui/game3/tm_case.lua create mode 100644 src/ui/game3/tm_case_chrome.lua create mode 100644 tests/game3_battle_anims_coverage_test.lua create mode 100644 tests/game3_battle_anims_phase1_test.lua create mode 100644 tests/game3_battle_anims_phase2_test.lua create mode 100644 tests/game3_battle_anims_phase3_test.lua create mode 100644 tests/game3_battle_anims_phase4_test.lua create mode 100644 tests/game3_battle_anims_pret_parity_test.lua create mode 100644 tests/game3_berry_pouch_1to1_test.lua create mode 100644 tests/game3_hall_of_fame_test.lua create mode 100644 tests/game3_step_events_queue_test.lua create mode 100644 tests/game3_tm_case_berry_pouch_extract_test.lua create mode 100644 tests/game3_town_map_test.lua diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index c6a4dd8f..2100fcd5 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -17,4 +17,4 @@ android.enableJetifier=false android.useAndroidX=true android.nonTransitiveRClass=true android.nonFinalResIds=true -app.name=gen1recomp (test) +app.name=gen1recomp (dev) diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 592ffeba..4002f364 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"mods":[],"schema_version":1} \ No newline at end of file +{"schema_version":1,"mods":[]} \ No newline at end of file diff --git a/src/core/Data.lua b/src/core/Data.lua index 5ba18879..eca32b99 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -109,6 +109,7 @@ end -- stamp one of these keys silently takes over from the engine. function Data:seedDefaults(version) version = version or require("src.core.GameVersion").get() + local gen = require("src.core.GameVersion").generation() local constants = self.constants or {} self.constants = constants self.field = self.field or {} @@ -117,6 +118,11 @@ function Data:seedDefaults(version) for key, value in pairs(CONSTANT_DEFAULTS) do if constants[key] == nil then constants[key] = copy(value) end end + if gen == 3 then + constants.dexSize = constants.dexSize or 386 + constants.dexDigits = 3 + return + end -- derived, not literal: a dataset with a different roster gets the right -- upper bound without 151 being written down anywhere if constants.dexSize == nil then @@ -271,7 +277,25 @@ end function Data:load() local dir = os.getenv("POKEPORT_DATA_DIR") - local gen2 = require("src.core.GameVersion").generation() == 2 + local gen = require("src.core.GameVersion").generation() + local gen2 = gen == 2 + local gen3 = gen == 3 + + if gen3 then + for _, name in ipairs(MODULES) do + self[name] = {} + end + for _, name in ipairs(OPTIONAL) do + self[name] = {} + end + self:seedDefaults() + local pristine = {} + self._pristineKeys = pristine + for key in pairs(self) do pristine[key] = true end + Logger.info("generated data initialized for gen3 (self-contained runtime)") + return + end + for _, name in ipairs(MODULES) do local ok, mod = loadModule(dir, name) if not ok then diff --git a/src/core/game3/bag.lua b/src/core/game3/bag.lua index 9d801b1f..cfac0dc8 100644 --- a/src/core/game3/bag.lua +++ b/src/core/game3/bag.lua @@ -26,16 +26,92 @@ function Bag.new() } end -local function ensure(bag) - if not bag then return nil end - if type(bag.pockets) ~= "table" then - Bag.migrate(bag) +local function slot_id_eq(a, b) + if a == nil or b == nil then return false end + local na, nb = ItemsData.toNumericId(a), ItemsData.toNumericId(b) + if na and nb then return na == nb end + return ItemsData.bagKey(a) == ItemsData.bagKey(b) +end + +local function find_slot(slots, id) + if not slots then return nil, nil end + for i, slot in ipairs(slots) do + if slot_id_eq(slot.id, id) then + return i, slot + end end + return nil, nil +end + +local function compact(slots) + local out = {} + if type(slots) ~= "table" then return out end + for _, slot in ipairs(slots) do + if slot.id and (tonumber(slot.qty) or 0) > 0 then + out[#out + 1] = { id = slot.id, qty = tonumber(slot.qty) or 0 } + end + end + return out +end + +local function sort_tm_pocket(slots) + -- pret SortPocketAndPlaceHMsFirst: HMs first, then TMs, stable-ish by id. + table.sort(slots, function(a, b) + local ah, bh = ItemsData.isHm(a.id), ItemsData.isHm(b.id) + if ah ~= bh then return ah end + local na = ItemsData.toNumericId(a.id) or 0 + local nb = ItemsData.toNumericId(b.id) or 0 + return na < nb + end) +end + +local function grant_key(bag, itemId) + local keySlots = bag.pockets.KEY_ITEMS + if not keySlots then return false end + for _, slot in ipairs(keySlots) do + if slot_id_eq(slot.id, itemId) then return true end + end + local cap = ItemsData.CAPACITY.KEY_ITEMS or 30 + if #keySlots >= cap then return false end + keySlots[#keySlots + 1] = { id = itemId, qty = 1 } + return true +end + +local function sanitize_pockets(bag) + if not bag or type(bag.pockets) ~= "table" then return end + local misplaced = {} for _, k in ipairs(POCKET_KEYS) do - bag.pockets[k] = bag.pockets[k] or {} + local slots = bag.pockets[k] or {} + local keep = {} + for _, slot in ipairs(slots) do + local correctPocket = ItemsData.pocketOf(slot.id) + if correctPocket ~= k then + misplaced[#misplaced + 1] = { id = slot.id, qty = tonumber(slot.qty) or 1, target = correctPocket } + else + keep[#keep + 1] = slot + end + end + bag.pockets[k] = compact(keep) + end + + for _, m in ipairs(misplaced) do + local targetSlots = bag.pockets[m.target] or {} + local idx, slot = find_slot(targetSlots, m.id) + if slot then + slot.qty = (tonumber(slot.qty) or 0) + m.qty + else + targetSlots[#targetSlots + 1] = { id = m.id, qty = m.qty } + end + bag.pockets[m.target] = compact(targetSlots) + end + + if bag.pockets.TM_CASE and #bag.pockets.TM_CASE > 0 then + sort_tm_pocket(bag.pockets.TM_CASE) + grant_key(bag, ItemsData.ITEM_TM_CASE) + end + if bag.pockets.BERRY_POUCH and #bag.pockets.BERRY_POUCH > 0 then + grant_key(bag, ItemsData.ITEM_BERRY_POUCH) end - bag.stacks = bag.stacks or {} - return bag end local function rebuild_stacks(bag) @@ -57,41 +133,17 @@ local function rebuild_stacks(bag) end end -local function slot_id_eq(a, b) - if a == nil or b == nil then return false end - local na, nb = ItemsData.toNumericId(a), ItemsData.toNumericId(b) - if na and nb then return na == nb end - return ItemsData.bagKey(a) == ItemsData.bagKey(b) -end - -local function find_slot(slots, id) - for i, slot in ipairs(slots) do - if slot_id_eq(slot.id, id) then - return i, slot - end +local function ensure(bag) + if not bag then return nil end + if type(bag.pockets) ~= "table" then + Bag.migrate(bag) end - return nil, nil -end - -local function compact(slots) - local out = {} - for _, slot in ipairs(slots) do - if slot.id and (tonumber(slot.qty) or 0) > 0 then - out[#out + 1] = { id = slot.id, qty = tonumber(slot.qty) or 0 } - end + for _, k in ipairs(POCKET_KEYS) do + bag.pockets[k] = bag.pockets[k] or {} end - return out -end - -local function sort_tm_pocket(slots) - -- pret SortPocketAndPlaceHMsFirst: HMs first, then TMs, stable-ish by id. - table.sort(slots, function(a, b) - local ah, bh = ItemsData.isHm(a.id), ItemsData.isHm(b.id) - if ah ~= bh then return ah end - local na = ItemsData.toNumericId(a.id) or 0 - local nb = ItemsData.toNumericId(b.id) or 0 - return na < nb - end) + sanitize_pockets(bag) + bag.stacks = bag.stacks or {} + return bag end --- Migrate legacy { stacks } or schema { items=… } into pockets. @@ -203,14 +255,6 @@ function Bag.canAdd(bag, id, qty) return true end -local function grant_key(bag, itemId) - local keySlots = bag.pockets.KEY_ITEMS - if find_slot(keySlots, itemId) then return true end - local cap = ItemsData.CAPACITY.KEY_ITEMS or 30 - if #keySlots >= cap then return false end - keySlots[#keySlots + 1] = { id = itemId, qty = 1 } - return true -end function Bag.add(bag, id, qty) bag = ensure(bag) @@ -226,7 +270,6 @@ function Bag.add(bag, id, qty) end local pocket = ItemsData.pocketOf(storeId) - local slots = bag.pockets[pocket] if pocket == "TM_CASE" and not Bag.has(bag, ItemsData.ITEM_TM_CASE, 1) then if not grant_key(bag, ItemsData.ITEM_TM_CASE) then @@ -242,6 +285,7 @@ function Bag.add(bag, id, qty) -- Flag handled by scripting later; bag just stores the key item. end + local slots = bag.pockets[pocket] local idx, slot = find_slot(slots, storeId) if slot then local have = tonumber(slot.qty) or 0 @@ -264,9 +308,11 @@ function Bag.remove(bag, id, qty) bag = ensure(bag) qty = math.max(1, math.floor(tonumber(qty) or 1)) if not id then return false end - local pocket = ItemsData.pocketOf(id) + local num = ItemsData.toNumericId(id) + local storeId = num or id + local pocket = ItemsData.pocketOf(storeId) local slots = bag.pockets[pocket] - local idx, slot = find_slot(slots, id) + local idx, slot = find_slot(slots, storeId) if not slot then return false end local have = tonumber(slot.qty) or 0 if have < qty then return false end @@ -282,6 +328,7 @@ function Bag.remove(bag, id, qty) return true end + function Bag.set(bag, id, qty) qty = Items.clampGame3(qty) local have = Bag.get(bag, id) @@ -323,8 +370,11 @@ function Bag.mergeFromHost(bag, hostInventory) for id, qty in pairs(hostInventory) do if type(id) == "string" and not id:find("BADGE", 1, true) then local n = tonumber(qty) or 0 - if n > 0 and Items.isHostSafe(id) then - Bag.add(bag, id, n) + if n > 0 then + local num = ItemsData.toNumericId(id) + if num or Items.isHostSafe(id) then + Bag.add(bag, num or id, n) + end end end end diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index f0e37b52..5504da35 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -28,6 +28,88 @@ Anim._present = { enemy = nil, } Anim._stage = nil +Anim._screenEffect = { + type = "none", + coeff = 0, + targetColor = { 1, 1, 1 }, +} +Anim._screenShader = nil + +local SCREEN_SHADER_SRC = [[ +extern int effectType; +extern float coeff; +extern vec3 targetColor; + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 c = Texel(tex, tc) * color; + if (effectType == 1) { + return vec4(vec3(1.0) - c.rgb, c.a); + } else if (effectType == 2) { + return vec4(mix(c.rgb, vec3(1.0), coeff), c.a); + } else if (effectType == 3) { + return vec4(mix(c.rgb, vec3(0.0), coeff), c.a); + } else if (effectType == 4) { + float luma = dot(c.rgb, vec3(0.299, 0.587, 0.114)); + return vec4(mix(c.rgb, vec3(luma), coeff), c.a); + } else if (effectType == 5) { + return vec4(mix(c.rgb, targetColor, coeff), c.a); + } + return c; +} +]] + +function Anim.setScreenEffect(opts) + if not opts or opts.type == "none" or opts.type == false then + Anim._screenEffect = { type = "none", coeff = 0, targetColor = { 1, 1, 1 } } + return + end + Anim._screenEffect = { + type = opts.type or "invert", + coeff = opts.coeff or 1, + targetColor = opts.targetColor or { 1, 1, 1 }, + } +end + +function Anim.screenEffect() + return Anim._screenEffect +end + +function Anim.beginScreenEffect() + local fx = Anim._screenEffect + if not fx or fx.type == "none" then return false end + if not (love and love.graphics and love.graphics.newShader) then return false end + + if Anim._screenShader == nil then + local ok, sh = pcall(love.graphics.newShader, SCREEN_SHADER_SRC) + Anim._screenShader = ok and sh or false + end + local sh = Anim._screenShader + if not sh then return false end + + local typeCode = 0 + if fx.type == "invert" then typeCode = 1 + elseif fx.type == "fade_white" then typeCode = 2 + elseif fx.type == "fade_black" or fx.type == "darken" then typeCode = 3 + elseif fx.type == "grayscale" then typeCode = 4 + elseif fx.type == "custom_blend" then typeCode = 5 + end + + if typeCode == 0 then return false end + + pcall(function() + sh:send("effectType", typeCode) + sh:send("coeff", fx.coeff or 1.0) + sh:send("targetColor", fx.targetColor or { 1, 1, 1 }) + love.graphics.setShader(sh) + end) + return true +end + +function Anim.endScreenEffect() + if love and love.graphics and love.graphics.setShader then + love.graphics.setShader() + end +end local function default_present(side) local z = (side == "player") and Anim.Z.PLAYER or Anim.Z.ENEMY @@ -43,6 +125,7 @@ local function default_present(side) scale = 1, sx = 1, sy = 1, + rotation = 0, displayHp = nil, displayMaxHp = nil, displayExp = nil, @@ -123,6 +206,10 @@ function Anim.reset(opts) if Anim._pack then Anim._vm:setPack(Anim._pack) end + Anim._screenEffect = { type = "none", coeff = 0, targetColor = { 1, 1, 1 } } + if love and love.graphics and love.graphics.setDefaultFilter then + pcall(love.graphics.setDefaultFilter, "nearest", "nearest") + end AnimSprites.reset() BallOpen.reset() end @@ -490,9 +577,16 @@ local GENERIC_MISS = { function Anim.scriptForMove(moveId) local pack = load_pack() - moveId = tonumber(moveId) or moveId + local numId = tonumber(moveId) + if not numId then + local okM, Moves = pcall(require, "src.core.game3.battle.moves") + if okM and Moves and Moves.numForName then + numId = Moves.numForName(moveId) + end + end + numId = numId or tonumber(moveId) or moveId if pack and pack.moves then - local s = pack.moves[moveId] or pack.moves[tostring(moveId)] + local s = pack.moves[numId] or pack.moves[tostring(numId)] or pack.moves[moveId] if s then return s end end return GENERIC_HIT diff --git a/src/core/game3/battle/anim_callbacks.lua b/src/core/game3/battle/anim_callbacks.lua index 475fcbf9..0b597349 100644 --- a/src/core/game3/battle/anim_callbacks.lua +++ b/src/core/game3/battle/anim_callbacks.lua @@ -784,6 +784,464 @@ AnimCallbacks.Conversion2 = AnimCallbacks.SpriteOnMonPos AnimCallbacks.RaiseSprite = AnimCallbacks.SpriteOnMonPos AnimCallbacks.ComplexPaletteBlend = AnimCallbacks.SpriteOnMonPos +--- pret AnimLightning — 5-frame lightning bolt strike downward on target (Thunderbolt, Thunder, Spark). +function AnimCallbacks.Lightning(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + sprite.z = AnimSprites.Z.GLOBAL_FRONT + end + sprite._step = sprite._step + 1 + local cellH = sprite._baseH or 32 + local totalFrames = 5 + local frame = math.min(totalFrames - 1, math.floor((sprite._step - 1) / 4)) + sprite.quadY = frame * cellH + if sprite._step >= totalFrames * 4 then + destroy(sprite) + end +end +AnimCallbacks.ElectricPuff = AnimCallbacks.Lightning +AnimCallbacks.ElectricBoltSegment = AnimCallbacks.Lightning +AnimCallbacks.ShockWaveLightning = AnimCallbacks.Lightning + +--- pret AnimSparkElectricityFlashing — multi-spark rotating flash around battler (Thunder Punch, Zap Cannon). +function AnimCallbacks.SparkElectricityFlashing(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + local args = sprite._args or {} + sprite._dur = math.max(12, tonumber(args[4]) or 24) + sprite._radius = tonumber(args[3]) or 20 + sprite._speed = tonumber(args[6]) or 8 + sprite._angle = (tonumber(args[5]) or 0) * (math.pi / 128) + sprite.z = AnimSprites.Z.GLOBAL_FRONT + end + sprite._step = sprite._step + 1 + sprite._angle = sprite._angle + (sprite._speed * 0.05) + sprite.ox = math.floor(math.cos(sprite._angle) * sprite._radius) + sprite.oy = math.floor(math.sin(sprite._angle) * (sprite._radius * 0.6)) + sprite.alpha = (sprite._step % 3 == 0) and 0.4 or 1.0 + if sprite._step >= sprite._dur then + destroy(sprite) + end +end +AnimCallbacks.ZapCannonSpark = AnimCallbacks.SparkElectricityFlashing +AnimCallbacks.SparkElectricity = AnimCallbacks.SparkElectricityFlashing +AnimCallbacks.VoltTackleOrbSlide = AnimCallbacks.SparkElectricityFlashing + +--- pret AnimBasicFistOrFoot — physical punch/kick strike on attacker/target (Mega Punch, Fire/Ice/Thunder Punch, Mega Kick). +function AnimCallbacks.BasicFistOrFoot(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + local args = sprite._args or {} + local animNum = tonumber(args[5]) or 0 + local dur = math.max(12, tonumber(args[3]) or 18) + sprite._dur = dur + local cellH = sprite._baseH or 32 + sprite.quadY = (animNum % 4) * cellH + sprite.z = AnimSprites.Z.GLOBAL_FRONT + end + sprite._step = sprite._step + 1 + local u = math.min(1, sprite._step / (sprite._dur or 18)) + local sc = (u < 0.3) and (0.7 + u * 1.5) or 1.0 + sprite.w = math.floor((sprite._baseW or 32) * sc) + sprite.h = math.floor((sprite._baseH or 32) * sc) + if sprite._step >= sprite._dur then + destroy(sprite) + end +end +AnimCallbacks.SpinningKickOrPunch = AnimCallbacks.BasicFistOrFoot +AnimCallbacks.SlidingKick = AnimCallbacks.BasicFistOrFoot +AnimCallbacks.JumpKick = AnimCallbacks.BasicFistOrFoot +AnimCallbacks.StompFoot = AnimCallbacks.BasicFistOrFoot +AnimCallbacks.CrossChopHand = AnimCallbacks.BasicFistOrFoot + +--- pret AnimNeedleArmSpike — projectile spikes flying in linear trajectory (Needle Arm, Pin Missile, Poison Sting, Spikes). +function AnimCallbacks.NeedleArmSpike(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + local args = sprite._args or {} + local dur = math.max(8, tonumber(args[5]) or 16) + sprite._dur = dur + local targetX = tonumber(args[3]) or 0 + local targetY = tonumber(args[4]) or 0 + sprite._dx = targetX + sprite._dy = targetY + sprite.rotation = math.atan2(targetY, targetX) + sprite.z = AnimSprites.Z.MID_FIELD + end + sprite._step = sprite._step + 1 + local u = math.min(1, sprite._step / sprite._dur) + sprite.ox = math.floor((sprite._dx or 0) * u) + sprite.oy = math.floor((sprite._dy or 0) * u) + if sprite._step >= sprite._dur then + destroy(sprite) + end +end +AnimCallbacks.Spikes = AnimCallbacks.NeedleArmSpike +AnimCallbacks.PoisonSting = AnimCallbacks.NeedleArmSpike +AnimCallbacks.PinMissile = AnimCallbacks.NeedleArmSpike + +--- pret AnimHitSplatRandom / AnimHitSplatHandleInvert — scattered multi-hit splats (Fury Swipes, Double Slap). +function AnimCallbacks.HitSplatRandom(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + sprite.ox = math.random(-16, 16) + sprite.oy = math.random(-16, 16) + sprite.z = AnimSprites.Z.GLOBAL_FRONT + end + sprite._step = sprite._step + 1 + local u = math.min(1, sprite._step / 8) + local sc = 0.6 + 0.5 * u + sprite.w = math.floor((sprite._baseW or 32) * sc) + sprite.h = math.floor((sprite._baseH or 32) * sc) + sprite.alpha = (sprite._step <= 8) and 1.0 or math.max(0, 1 - (sprite._step - 8) / 6) + if sprite._step >= 14 then + destroy(sprite) + end +end +AnimCallbacks.HitSplatHandleInvert = AnimCallbacks.HitSplatRandom + +--- pret AnimMudSportDirt — mud splatter arching and dripping (Mud-Slap, Mud Shot, Mud Sport). +function AnimCallbacks.MudSportDirt(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + local args = sprite._args or {} + sprite._dur = math.max(10, tonumber(args[3]) or 20) + sprite._vx = math.random(-12, 12) + sprite._vy = -math.random(15, 30) + sprite.z = AnimSprites.Z.GLOBAL_BEHIND + end + sprite._step = sprite._step + 1 + local t = sprite._step + sprite.ox = math.floor(sprite._vx * (t / 10)) + sprite.oy = math.floor(sprite._vy * (t / 10) + (0.5 * 3.8 * (t / 10)^2)) + sprite.alpha = math.max(0, 1 - sprite._step / sprite._dur) + if sprite._step >= sprite._dur then + destroy(sprite) + end +end +AnimCallbacks.MudSlap = AnimCallbacks.MudSportDirt +AnimCallbacks.MudShot = AnimCallbacks.MudSportDirt + +--- pret AnimSmallDriftingBubbles (pokefirered/src/battle_anim_water.c:1011) +-- args: [1]=dx, [2]=dy +function AnimCallbacks.SmallDriftingBubbles(sprite) + if not sprite._inited then + sprite._inited = true + local r1 = math.random(0, 255) + 256 + local r2 = math.random(0, 511) + if r2 > 255 then r2 = 256 - r2 end + sprite.data[0] = 0 -- step counter + sprite.data[1] = r1 -- Q8.8 vx + sprite.data[2] = r2 -- Q8.8 vy + sprite.data[3] = 0 -- Q8.8 x accumulator + sprite.data[4] = 0 -- Q8.8 y accumulator + sprite.z = AnimSprites.Z.FRONT + end + + sprite.data[3] = (sprite.data[3] or 0) + (sprite.data[1] or 256) + sprite.data[4] = (sprite.data[4] or 0) + (sprite.data[2] or 128) + + if (sprite.data[1] % 2) == 1 then + sprite.ox = -math.floor((sprite.data[3] or 0) / 256) + else + sprite.ox = math.floor((sprite.data[3] or 0) / 256) + end + sprite.oy = math.floor((sprite.data[4] or 0) / 256) + + sprite.data[0] = (sprite.data[0] or 0) + 1 + if sprite.data[0] >= 21 then + destroy(sprite) + end +end + +--- pret AnimBubbleEffect (pokefirered/src/battle_anim_water.c:286) +-- Floating bubble with horizontal sine oscillation and pop +function AnimCallbacks.BubbleEffect(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 -- step + sprite.data[1] = 0 -- sine phase + sprite.data[2] = math.random(18, 28) -- duration + sprite.z = AnimSprites.Z.FRONT + end + + sprite.data[0] = (sprite.data[0] or 0) + 1 + sprite.data[1] = ((sprite.data[1] or 0) + 12) % 256 + sprite.ox = math.floor(math.sin((sprite.data[1] / 256) * 2 * math.pi) * 6) + sprite.oy = -(sprite.data[0] * 1.2) + + local u = sprite.data[0] / sprite.data[2] + local sc = 0.6 + 0.5 * u + sprite.w = math.floor((sprite._baseW or 16) * sc) + sprite.h = math.floor((sprite._baseH or 16) * sc) + sprite.alpha = math.max(0, 1.0 - u * 0.3) + + if sprite.data[0] >= sprite.data[2] then + destroy(sprite) + end +end +AnimCallbacks.SmallBubblePair = AnimCallbacks.BubbleEffect +AnimCallbacks.WaterPulseBubble = AnimCallbacks.BubbleEffect +AnimCallbacks.WaterGunDroplet = AnimCallbacks.BubbleEffect +AnimCallbacks.WaterPulseRing = AnimCallbacks.BubbleEffect + +--- pret AnimFirePlume (pokefirered/src/battle_anim_fire.c:486) +-- arg 0: dx, arg 1: dy, arg 2: duration, arg 3: dy_step, arg 4: dx_step, arg 5: unused +function AnimCallbacks.FirePlume(sprite) + if not sprite._inited then + sprite._inited = true + local args = sprite._args or {} + local dx = tonumber(args[1]) or 0 + local dy = tonumber(args[2]) or 0 + local dur = tonumber(args[3]) or 20 + local dyStep = tonumber(args[4]) or -2 + local dxStep = tonumber(args[5]) or 0 + if sprite._reversed then + dxStep = -dxStep + end + sprite.data[0] = 0 -- step + sprite.data[1] = dur -- lifetime + sprite.data[2] = dxStep -- x velocity + sprite.data[3] = dyStep -- y velocity + sprite.data[4] = dur -- move duration + sprite.z = AnimSprites.Z.FRONT + end + + sprite.data[0] = (sprite.data[0] or 0) + 1 + if sprite.data[0] < (sprite.data[4] or 20) then + sprite.ox = (sprite.ox or 0) + (sprite.data[2] or 0) + sprite.oy = (sprite.oy or 0) + (sprite.data[3] or -2) + end + + 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.data[0] - 1) / 3) % totalFrames) * cellH + sprite.alpha = math.max(0, 1.0 - (sprite.data[0] / (sprite.data[1] or 20)) * 0.4) + + if sprite.data[0] >= (sprite.data[1] or 20) then + destroy(sprite) + end +end + +--- pret AnimFireSpiralOutward (pokefirered/src/battle_anim_fire.c:703) +-- arg 0: unused, arg 1: unused, arg 2: duration, arg 3: startDelay +function AnimCallbacks.FireSpiralOutward(sprite) + if not sprite._inited then + sprite._inited = true + local args = sprite._args or {} + sprite.data[0] = tonumber(args[4]) or 0 -- delay + sprite.data[1] = tonumber(args[3]) or 24 -- duration + sprite.data[2] = 0 -- radius Q8.8 + sprite.data[3] = (tonumber(args[5]) or 0) * 32 -- angle + sprite.z = AnimSprites.Z.FRONT + end + + if (sprite.data[0] or 0) > 0 then + sprite.data[0] = sprite.data[0] - 1 + sprite.alpha = 0 + return + end + sprite.alpha = 1 + + local angle = sprite.data[3] or 0 + local radius = math.floor((sprite.data[2] or 0) / 256) + sprite.ox = math.floor(math.sin((angle / 256) * 2 * math.pi) * radius) + sprite.oy = math.floor(math.cos((angle / 256) * 2 * math.pi) * (radius * 0.6)) + sprite.data[3] = (angle + 10) % 256 + sprite.data[2] = (sprite.data[2] or 0) + 0xD0 + + local cellH = sprite._baseH or 16 + 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(angle / 32) % totalFrames) * cellH + + sprite.data[1] = (sprite.data[1] or 24) - 1 + if sprite.data[1] <= 0 then + destroy(sprite) + end +end + +--- pret AnimElectricity / AnimSparkElectricity (pokefirered/src/battle_anim_electric.c:85, 140) +function AnimCallbacks.Electricity(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 16 -- duration + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + sprite.ox = math.random(-8, 8) + sprite.oy = math.random(-8, 8) + sprite.hFlip = (math.random() > 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.data[0] - 1) / 2) % totalFrames) * cellH + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.SparkElectricity = AnimCallbacks.Electricity +AnimCallbacks.ThunderboltSegment = AnimCallbacks.Electricity + +--- pret AnimSolarBeamBigOrb / AnimSolarBeamSmallOrb (pokefirered/src/battle_anim_effects_2.c:400) +function AnimCallbacks.SolarBeamBigOrb(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 24 + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + local progress = sprite.data[0] / sprite.data[1] + local sc = 0.5 + 0.7 * math.min(1.0, progress * 1.5) + sprite.w = math.floor((sprite._baseW or 32) * sc) + sprite.h = math.floor((sprite._baseH or 32) * sc) + sprite.rotation = (sprite.rotation or 0) + 0.15 + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.SolarBeamSmallOrb = AnimCallbacks.SolarBeamBigOrb +AnimCallbacks.GrowingChargeOrb = AnimCallbacks.SolarBeamBigOrb + +--- pret AnimWeatherBallDown / AnimWeatherBallUp (pokefirered/src/battle_anim_effects_2.c:650) +function AnimCallbacks.WeatherBallDown(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 20 + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + local progress = sprite.data[0] / sprite.data[1] + sprite.oy = math.floor(progress * 48) + sprite.rotation = (sprite.rotation or 0) + 0.1 + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.WeatherBallUp = AnimCallbacks.WeatherBallDown +AnimCallbacks.ZapCannonBall = AnimCallbacks.WeatherBallDown + +--- pret AnimIceEffectParticle / AnimSwirlingSnowball (pokefirered/src/battle_anim_ice.c:95, 180) +function AnimCallbacks.IceEffectParticle(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 20 + sprite.data[2] = (math.random() - 0.5) * 1.5 -- vx + sprite.data[3] = 1.2 + math.random() * 0.8 -- vy + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + sprite.ox = math.floor((sprite.ox or 0) + sprite.data[2]) + sprite.oy = math.floor((sprite.oy or 0) + sprite.data[3]) + sprite.rotation = (sprite.rotation or 0) + 0.12 + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.SwirlingSnowball = AnimCallbacks.IceEffectParticle +AnimCallbacks.IceBallChunk = AnimCallbacks.IceEffectParticle + +--- pret AnimDirtPlumeParticle / AnimRockScatter / AnimDirtScatter (pokefirered/src/battle_anim_ground.c:110, 210) +function AnimCallbacks.DirtPlumeParticle(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 22 + sprite.data[2] = (math.random() - 0.5) * 2.0 -- vx + sprite.data[3] = -2.5 - math.random() * 1.5 -- vy initial upward + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + sprite.data[3] = sprite.data[3] + 0.25 -- gravity + sprite.ox = math.floor((sprite.ox or 0) + sprite.data[2]) + sprite.oy = math.floor((sprite.oy or 0) + sprite.data[3]) + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.RockScatter = AnimCallbacks.DirtPlumeParticle +AnimCallbacks.DirtScatter = AnimCallbacks.DirtPlumeParticle +AnimCallbacks.MudSportDirt = AnimCallbacks.DirtPlumeParticle +AnimCallbacks.SandAttackMud = AnimCallbacks.DirtPlumeParticle +AnimCallbacks.MudSand = AnimCallbacks.DirtPlumeParticle + +--- pret AnimWaveFromCenterOfTarget / AnimAirWaveCrescent / AnimSoundWave (pokefirered/src/battle_anim_sound.c:220, 270) +function AnimCallbacks.WaveFromCenterOfTarget(sprite) + if not sprite._inited then + sprite._inited = true + sprite.data[0] = 0 + sprite.data[1] = 20 + sprite.z = AnimSprites.Z.FRONT + end + sprite.data[0] = (sprite.data[0] or 0) + 1 + local progress = sprite.data[0] / sprite.data[1] + local sc = 0.4 + 1.2 * progress + sprite.w = math.floor((sprite._baseW or 32) * sc) + sprite.h = math.floor((sprite._baseH or 32) * sc) + sprite.alpha = math.max(0, 1.0 - progress) + + if sprite.data[0] >= sprite.data[1] then + destroy(sprite) + end +end +AnimCallbacks.AirWaveCrescent = AnimCallbacks.WaveFromCenterOfTarget +AnimCallbacks.SoundWave = AnimCallbacks.WaveFromCenterOfTarget + +--- pret AnimWillOWispFire / AnimWillOWispOrb — orbiting ghostly flames (Will-O-Wisp, Fire Spin). +function AnimCallbacks.WillOWispFire(sprite) + if not sprite._inited then + sprite._inited = true + sprite._step = 0 + sprite._dur = 32 + sprite._angle = (sprite.data[0] or 0) * (math.pi / 4) + sprite._radius = 28 + sprite.z = AnimSprites.Z.FRONT + end + sprite._step = sprite._step + 1 + sprite._angle = sprite._angle + 0.15 + sprite._radius = math.max(4, sprite._radius - 0.7) + sprite.ox = math.floor(math.cos(sprite._angle) * sprite._radius) + sprite.oy = math.floor(math.sin(sprite._angle) * (sprite._radius * 0.6)) + sprite.alpha = math.max(0, 1.0 - sprite._step / sprite._dur) + if sprite._step >= sprite._dur then + destroy(sprite) + end +end +AnimCallbacks.WillOWispOrb = AnimCallbacks.WillOWispFire +AnimCallbacks.IngrainOrb = AnimCallbacks.WillOWispFire +AnimCallbacks.IngrainRoot = AnimCallbacks.SpriteOnMonPos +AnimCallbacks.FrenzyPlantRoot = AnimCallbacks.SpriteOnMonPos +AnimCallbacks.ConstrictBinding = AnimCallbacks.SpriteOnMonPos +AnimCallbacks.LeechSeed = AnimCallbacks.ThrowProjectile +AnimCallbacks.ThunderWave = AnimCallbacks.SpriteOnMonPos +AnimCallbacks.AssistPawprint = AnimCallbacks.SpriteOnMonPos + function AnimCallbacks.SimpleFadeOut(sprite) sprite.data[0] = (sprite.data[0] or 0) + 1 local life = sprite.data[0] diff --git a/src/core/game3/battle/anim_tasks.lua b/src/core/game3/battle/anim_tasks.lua index 24c43874..46978f9d 100644 --- a/src/core/game3/battle/anim_tasks.lua +++ b/src/core/game3/battle/anim_tasks.lua @@ -7,11 +7,48 @@ local AnimTasks = {} AnimTasks.MAX = 48 +local SINE_TABLE = { + 0, 6, 12, 18, 25, 31, 37, 43, 49, 56, 62, 68, 74, 80, 86, 92, + 97, 103, 109, 115, 120, 126, 131, 136, 142, 147, 152, 157, 162, 167, 171, 176, + 181, 185, 189, 193, 197, 201, 205, 209, 212, 216, 219, 222, 225, 228, 231, 234, + 236, 238, 241, 243, 244, 246, 248, 249, 251, 252, 253, 254, 255, 255, 256, 256, + 256, 256, 256, 255, 255, 254, 253, 252, 251, 249, 248, 246, 244, 243, 241, 238, + 236, 234, 231, 228, 225, 222, 219, 216, 212, 209, 205, 201, 197, 193, 189, 185, + 181, 176, 171, 167, 162, 157, 152, 147, 142, 136, 131, 126, 120, 115, 109, 103, + 97, 92, 86, 80, 74, 68, 62, 56, 49, 43, 37, 31, 25, 18, 12, 6, + 0, -6, -12, -18, -25, -31, -37, -43, -49, -56, -62, -68, -74, -80, -86, -92, + -97, -103, -109, -115, -120, -126, -131, -136, -142, -147, -152, -157, -162, -167, -171, -176, + -181, -185, -189, -193, -197, -201, -205, -209, -212, -216, -219, -222, -225, -228, -231, -234, + -236, -238, -241, -243, -244, -246, -248, -249, -251, -252, -253, -254, -255, -255, -256, -256, + -256, -256, -256, -255, -255, -254, -253, -252, -251, -249, -248, -246, -244, -243, -241, -238, + -236, -234, -231, -228, -225, -222, -219, -216, -212, -209, -205, -201, -197, -193, -189, -185, + -181, -176, -171, -167, -162, -157, -152, -147, -142, -136, -131, -126, -120, -115, -109, -103, + -97, -92, -86, -80, -74, -68, -62, -56, -49, -43, -37, -31, -25, -18, -12, -6, +} + +local function Sin(index, amp) + index = math.floor(tonumber(index) or 0) % 256 + local val = SINE_TABLE[index + 1] or 0 + return math.floor((val * (amp or 0)) / 256) +end + +local function Cos(index, amp) + return Sin((tonumber(index) or 0) + 64, amp) +end + local function clear_task(t) t.active = false t.func = nil + t.draw = nil + t.z = nil t.name = nil t.priority = 0 + t._inited = nil + t._p = nil + t._side = nil + t._origZ = nil + t._alpha = nil + t._particles = nil for i = 0, 15 do t.data[i] = 0 end @@ -59,68 +96,465 @@ end -- Registry: name → function(task, vm) AnimTasks.REGISTRY = {} ---- AnimTask_ShakeMon: args target, xShake, yShake, duration, amplitude-ish +--- pret AnimTask_ShakeMon (pokefirered/src/battle_anim_mon_movement.c:94) +-- arg 0: battler, arg 1: x offset, arg 2: y offset, arg 3: num shakes, arg 4: delay function AnimTasks.ShakeMon(t, vm) - local side = vm:resolveBattlerSide(t.data[0]) local Anim = require("src.core.game3.battle.anim") - local p = Anim.present(side) + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0]) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._xOff = tonumber(t.data[1]) or 0 + t._yOff = tonumber(t.data[2]) or 0 + t._numShakes = math.max(1, tonumber(t.data[3]) or 1) + t._delay = math.max(0, tonumber(t.data[4]) or 1) + t._timer = t._delay + t._p.ox = t._xOff + t._p.oy = t._yOff + return + end + local p = t._p if not p then destroy_task(t) return end - local xAmp = math.max(4, tonumber(t.data[1]) or 3) - local yAmp = tonumber(t.data[2]) or 0 - local dur = math.max(10, tonumber(t.data[3]) or 6) - local frame = t.data[14] or 0 - t.data[14] = frame + 1 - local phase = frame % 4 - local sx = (phase == 0 or phase == 3) and xAmp or -xAmp - local sy = (phase == 0 or phase == 3) and yAmp or -yAmp - if vm.isReversed then sx = -sx end - p.ox = sx - p.oy = sy - p.flash = frame - if frame >= dur then - p.ox = 0 - p.oy = 0 - p.flash = 0 - destroy_task(t) + if (t._timer or 0) <= 0 then + p.ox = (p.ox == 0) and t._xOff or 0 + p.oy = (p.oy == 0) and t._yOff or 0 + t._timer = t._delay + t._numShakes = t._numShakes - 1 + if t._numShakes <= 0 then + p.ox = 0 + p.oy = 0 + destroy_task(t) + end + else + t._timer = t._timer - 1 end end AnimTasks.REGISTRY.ShakeMon = AnimTasks.ShakeMon AnimTasks.REGISTRY.AnimTask_ShakeMon = AnimTasks.ShakeMon -AnimTasks.REGISTRY.ShakeMon2 = AnimTasks.ShakeMon -AnimTasks.REGISTRY.AnimTask_ShakeMon2 = AnimTasks.ShakeMon -AnimTasks.REGISTRY.ShakeMonInPlace = AnimTasks.ShakeMon -AnimTasks.REGISTRY.AnimTask_ShakeMonInPlace = AnimTasks.ShakeMon ---- Horizontal lunge of attacker (visual task style used by gHorizontalLungeSpriteTemplate CB). -function AnimTasks.HorizontalLunge(t, vm) +--- pret AnimTask_ShakeMon2 (pokefirered/src/battle_anim_mon_movement.c:146) +-- arg 0: battler, arg 1: x offset, arg 2: y offset, arg 3: num shakes, arg 4: delay +function AnimTasks.ShakeMon2(t, vm) local Anim = require("src.core.game3.battle.anim") - local side = vm:attackerSide() - local p = Anim.present(side) + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0]) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._xOff = tonumber(t.data[1]) or 0 + t._yOff = tonumber(t.data[2]) or 0 + t._numShakes = math.max(1, tonumber(t.data[3]) or 1) + t._delay = math.max(0, tonumber(t.data[4]) or 1) + t._timer = t._delay + t._p.ox = t._xOff + t._p.oy = t._yOff + return + end + local p = t._p if not p then destroy_task(t) return end - local speed = math.max(6, tonumber(t.data[0]) or 4) - local dur = math.max(6, tonumber(t.data[1]) or 4) - local frame = t.data[14] or 0 - t.data[14] = frame + 1 - local dir = vm.isReversed and -1 or 1 - if frame < dur then - p.ox = dir * speed * 3 - elseif frame < dur * 2 then - p.ox = 0 + if (t._timer or 0) <= 0 then + p.ox = (p.ox == t._xOff) and -t._xOff or t._xOff + p.oy = (p.oy == t._yOff) and -t._yOff or t._yOff + t._timer = t._delay + t._numShakes = t._numShakes - 1 + if t._numShakes <= 0 then + p.ox = 0 + p.oy = 0 + destroy_task(t) + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.ShakeMon2 = AnimTasks.ShakeMon2 +AnimTasks.REGISTRY.AnimTask_ShakeMon2 = AnimTasks.ShakeMon2 + +--- pret AnimTask_ShakeMonInPlace (pokefirered/src/battle_anim_mon_movement.c:232) +-- arg 0: battler, arg 1: x offset, arg 2: y offset, arg 3: num shakes, arg 4: delay +function AnimTasks.ShakeMonInPlace(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0]) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + local xOff = tonumber(t.data[1]) or 0 + local yOff = tonumber(t.data[2]) or 0 + t._p.ox = (t._p.ox or 0) + xOff + t._p.oy = (t._p.oy or 0) + yOff + t._step = 0 + t._numShakes = math.max(1, tonumber(t.data[3]) or 1) + t._timer = 0 + t._delay = math.max(0, tonumber(t.data[4]) or 1) + t._dx = xOff * 2 + t._dy = yOff * 2 + return + end + local p = t._p + if not p then destroy_task(t) + return + end + if (t._timer or 0) <= 0 then + if (t._step % 2) == 1 then + p.ox = (p.ox or 0) + t._dx + p.oy = (p.oy or 0) + t._dy + else + p.ox = (p.ox or 0) - t._dx + p.oy = (p.oy or 0) - t._dy + end + t._timer = t._delay + t._step = t._step + 1 + if t._step >= t._numShakes then + if (t._step % 2) == 1 then + p.ox = (p.ox or 0) + math.floor(t._dx / 2) + p.oy = (p.oy or 0) + math.floor(t._dy / 2) + else + p.ox = (p.ox or 0) - math.floor(t._dx / 2) + p.oy = (p.oy or 0) - math.floor(t._dy / 2) + end + destroy_task(t) + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.ShakeMonInPlace = AnimTasks.ShakeMonInPlace +AnimTasks.REGISTRY.AnimTask_ShakeMonInPlace = AnimTasks.ShakeMonInPlace + +--- pret AnimTask_ShakeAndSinkMon (pokefirered/src/battle_anim_mon_movement.c:294) +-- arg 0: battler, arg 1: x offset, arg 2: frame delay, arg 3: downward speed (subpixel Q8.8), arg 4: duration +function AnimTasks.ShakeAndSinkMon(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0]) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._xOff = tonumber(t.data[1]) or 0 + t._delay = math.max(1, tonumber(t.data[2]) or 1) + t._downSpeed = tonumber(t.data[3]) or 0 + t._duration = math.max(1, tonumber(t.data[4]) or 1) + t._delayCounter = 0 + t._subY = 0 + t._p.ox = t._xOff + return + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._delayCounter = t._delayCounter + 1 + if t._delayCounter >= t._delay then + t._delayCounter = 0 + if p.ox == t._xOff then + t._xOff = -t._xOff + end + p.ox = p.ox + t._xOff + end + t._subY = t._subY + t._downSpeed + p.oy = math.floor(t._subY / 256) + t._duration = t._duration - 1 + if t._duration <= 0 then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.ShakeAndSinkMon = AnimTasks.ShakeAndSinkMon +AnimTasks.REGISTRY.AnimTask_ShakeAndSinkMon = AnimTasks.ShakeAndSinkMon + +--- pret DoHorizontalLunge / ReverseHorizontalLungeDirection (pokefirered/src/battle_anim_mon_movement.c:388) +-- arg 0: duration of single lunge direction, arg 1: x pixel delta per frame +function AnimTasks.HorizontalLunge(t, vm) + local Anim = require("src.core.game3.battle.anim") + local AnimVm = require("src.core.game3.battle.anim_vm") + if not t._inited then + t._inited = true + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._dur = math.max(1, tonumber(t.data[0]) or 4) + local delta = tonumber(t.data[1]) or 4 + if side ~= "player" then + delta = -delta + end + t._delta = delta + t._frame = 0 + t._origZ = t._p.z + -- Dynamic Z-indexing: elevate attacker above target and healthbox during lunge + t._p.z = AnimVm.Z.FRONT + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._frame = t._frame + 1 + if t._frame <= t._dur then + p.ox = (p.ox or 0) + t._delta + elseif t._frame <= t._dur * 2 then + p.ox = (p.ox or 0) - t._delta else p.ox = 0 + if t._origZ then p.z = t._origZ end destroy_task(t) end end AnimTasks.REGISTRY.HorizontalLunge = AnimTasks.HorizontalLunge +AnimTasks.REGISTRY.DoHorizontalLunge = AnimTasks.HorizontalLunge +AnimTasks.REGISTRY.ReverseHorizontalLungeDirection = AnimTasks.HorizontalLunge + +--- pret DoVerticalDip / ReverseVerticalDipDirection (pokefirered/src/battle_anim_mon_movement.c:416) +-- arg 0: duration of single dip direction, arg 1: y pixel delta per frame, arg 2: battler +function AnimTasks.VerticalDip(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[2] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._dur = math.max(1, tonumber(t.data[0]) or 4) + t._delta = tonumber(t.data[1]) or 4 + t._frame = 0 + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._frame = t._frame + 1 + if t._frame <= t._dur then + p.oy = (p.oy or 0) + t._delta + elseif t._frame <= t._dur * 2 then + p.oy = (p.oy or 0) - t._delta + else + p.oy = 0 + destroy_task(t) + end +end + +AnimTasks.REGISTRY.VerticalDip = AnimTasks.VerticalDip +AnimTasks.REGISTRY.DoVerticalDip = AnimTasks.VerticalDip +AnimTasks.REGISTRY.ReverseVerticalDipDirection = AnimTasks.VerticalDip + +--- pret SlideMonToOriginalPos (pokefirered/src/battle_anim_mon_movement.c:443) +-- arg 0: 0=attacker, 1=target; arg 1: dir (0=both, 1=horiz, 2=vert); arg 2: duration +function AnimTasks.SlideMonToOriginalPos(t, vm) + local Anim = require("src.core.game3.battle.anim") + local AnimVm = require("src.core.game3.battle.anim_vm") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._dir = tonumber(t.data[1]) or 0 + t._dur = math.max(1, tonumber(t.data[2]) or 10) + t._startX = t._p.ox or 0 + t._startY = t._p.oy or 0 + t._deltaX_fp = math.floor((-t._startX * 256) / t._dur) + t._deltaY_fp = math.floor((-t._startY * 256) / t._dur) + t._accumX = 0 + t._accumY = 0 + t._ticks = t._dur + t._p.z = (side == "player") and AnimVm.Z.PLAYER or AnimVm.Z.ENEMY + end + local p = t._p + if not p then + destroy_task(t) + return + end + if t._ticks <= 0 then + if t._dir == 0 or t._dir == 1 then p.ox = 0 end + if t._dir == 0 or t._dir == 2 then p.oy = 0 end + p.z = (t._side == "player") and AnimVm.Z.PLAYER or AnimVm.Z.ENEMY + destroy_task(t) + return + end + t._ticks = t._ticks - 1 + t._accumX = t._accumX + t._deltaX_fp + t._accumY = t._accumY + t._deltaY_fp + if t._dir == 0 or t._dir == 1 then + p.ox = t._startX + math.floor(t._accumX / 256) + end + if t._dir == 0 or t._dir == 2 then + p.oy = t._startY + math.floor(t._accumY / 256) + end + if t._ticks <= 0 then + if t._dir == 0 or t._dir == 1 then p.ox = 0 end + if t._dir == 0 or t._dir == 2 then p.oy = 0 end + p.z = (t._side == "player") and AnimVm.Z.PLAYER or AnimVm.Z.ENEMY + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SlideMonToOriginalPos = AnimTasks.SlideMonToOriginalPos +AnimTasks.REGISTRY.AnimTask_SlideMonToOriginalPos = AnimTasks.SlideMonToOriginalPos + +--- pret SlideMonToOffset (pokefirered/src/battle_anim_mon_movement.c:500) +-- arg 0: 0=attacker, 1=target; arg 1: targetX; arg 2: targetY; arg 3: mirrorY; arg 4: duration +function AnimTasks.SlideMonToOffset(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + local targetX = tonumber(t.data[1]) or 0 + local targetY = tonumber(t.data[2]) or 0 + local mirrorY = tonumber(t.data[3]) or 0 + if side ~= "player" then + targetX = -targetX + if mirrorY == 1 then targetY = -targetY end + end + t._dur = math.max(1, tonumber(t.data[4]) or 10) + t._startX = t._p.ox or 0 + t._startY = t._p.oy or 0 + t._targetX = targetX + t._targetY = targetY + t._deltaX_fp = math.floor(((targetX - t._startX) * 256) / t._dur) + t._deltaY_fp = math.floor(((targetY - t._startY) * 256) / t._dur) + t._accumX = 0 + t._accumY = 0 + t._ticks = t._dur + end + local p = t._p + if not p then + destroy_task(t) + return + end + if t._ticks <= 0 then + p.ox = t._targetX + p.oy = t._targetY + destroy_task(t) + return + end + t._ticks = t._ticks - 1 + t._accumX = t._accumX + t._deltaX_fp + t._accumY = t._accumY + t._deltaY_fp + p.ox = t._startX + math.floor(t._accumX / 256) + p.oy = t._startY + math.floor(t._accumY / 256) + if t._ticks <= 0 then + p.ox = t._targetX + p.oy = t._targetY + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SlideMonToOffset = AnimTasks.SlideMonToOffset +AnimTasks.REGISTRY.AnimTask_SlideMonToOffset = AnimTasks.SlideMonToOffset + +--- pret SlideMonToOffsetAndBack (pokefirered/src/battle_anim_mon_movement.c:529) +-- arg 0: battler; arg 1: targetX; arg 2: targetY; arg 3: mirrorY; arg 4: duration; arg 5: resetAtEnd +function AnimTasks.SlideMonToOffsetAndBack(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + local targetX = tonumber(t.data[1]) or 0 + local targetY = tonumber(t.data[2]) or 0 + local mirrorY = tonumber(t.data[3]) or 0 + if side ~= "player" then + targetX = -targetX + if mirrorY == 1 then targetY = -targetY end + end + t._dur = math.max(1, tonumber(t.data[4]) or 10) + t._resetAtEnd = (tonumber(t.data[5]) or 0) == 1 + t._startX = t._p.ox or 0 + t._startY = t._p.oy or 0 + t._targetX = targetX + t._targetY = targetY + t._deltaX_fp = math.floor(((targetX - t._startX) * 256) / t._dur) + t._deltaY_fp = math.floor(((targetY - t._startY) * 256) / t._dur) + t._accumX = 0 + t._accumY = 0 + t._ticks = t._dur + end + local p = t._p + if not p then + destroy_task(t) + return + end + if t._ticks <= 0 then + if t._resetAtEnd then + p.ox = 0 + p.oy = 0 + else + p.ox = t._targetX + p.oy = t._targetY + end + destroy_task(t) + return + end + t._ticks = t._ticks - 1 + t._accumX = t._accumX + t._deltaX_fp + t._accumY = t._accumY + t._deltaY_fp + p.ox = t._startX + math.floor(t._accumX / 256) + p.oy = t._startY + math.floor(t._accumY / 256) + if t._ticks <= 0 then + if t._resetAtEnd then + p.ox = 0 + p.oy = 0 + else + p.ox = t._targetX + p.oy = t._targetY + end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SlideMonToOffsetAndBack = AnimTasks.SlideMonToOffsetAndBack +AnimTasks.REGISTRY.AnimTask_SlideMonToOffsetAndBack = AnimTasks.SlideMonToOffsetAndBack +AnimTasks.REGISTRY.SlideMon = AnimTasks.SlideMonToOffsetAndBack --- Sound tasks: finish on a short timer (cry audio optional later). function AnimTasks.SoundTaskWait(t, _vm) @@ -149,7 +583,6 @@ function AnimTasks.BlendColorCycle(t, vm) end end if frame >= 8 then - -- restore rough defaults if pal then pal[1] = { 1, 1, 1, 1 } pal[2] = { 1, 0.9, 0.2, 1 } @@ -196,48 +629,6 @@ 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") @@ -264,106 +655,342 @@ end AnimTasks.REGISTRY.DarkenBattleAnimBg = AnimTasks.DarkenBattleAnimBg AnimTasks.REGISTRY.AnimTask_DarkenBattleAnimBg = AnimTasks.DarkenBattleAnimBg ---- pret AnimTask_BowMon: squashes attacker vertically for meditation / calming. +--- pret AnimBowMon (pokefirered/src/battle_anim_effects_1.c:4451) +-- arg 0: step mode (0=bow start, 1=return slide, 2=hold & unbow, 3=end) function AnimTasks.BowMon(t, vm) local Anim = require("src.core.game3.battle.anim") - local side = vm:attackerSide() - local p = Anim.present(side) + if not t._inited then + t._inited = true + local stepMode = tonumber(t.data[0]) or 0 + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._mode = stepMode + t._frame = 0 + local dir = (side == "player") and -1 or 1 + if stepMode == 0 then + t._dx = dir * 2 + t._dur = 6 + elseif stepMode == 1 then + t._dx = -dir * 3 + t._dur = 4 + elseif stepMode == 2 then + t._dur = 8 + else + t._dur = 1 + end + end + local p = t._p 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 + t._frame = t._frame + 1 + if t._mode == 0 then + if t._frame <= t._dur then + p.ox = (p.ox or 0) + t._dx + else + p.rotation = (t._side == "player") and -0.05 or 0.05 + if t._frame > t._dur + 3 then + destroy_task(t) + end + end + elseif t._mode == 1 then + if t._frame <= t._dur then + p.ox = (p.ox or 0) + t._dx + else + destroy_task(t) + end + elseif t._mode == 2 then + if t._frame > t._dur then + p.rotation = 0 + destroy_task(t) + end else - p.sy = 1.0 + p.rotation = 0 destroy_task(t) end end AnimTasks.REGISTRY.BowMon = AnimTasks.BowMon +AnimTasks.REGISTRY.AnimBowMon = AnimTasks.BowMon AnimTasks.REGISTRY.AnimTask_BowMon = AnimTasks.BowMon ---- pret AnimTask_ShakeMonOrBattleTerrain: screen / ground shake. -function AnimTasks.ShakeMonOrBattleTerrain(t, vm) - AnimTasks.ShakeMon(t, vm) +--- pret AnimShakeMonOrBattleTerrain (pokefirered/src/battle_anim_normal.c:771) +-- arg 0: offset, arg 1: frame delay, arg 2: duration, arg 3: target type (0=BG3_X, 1=BG3_Y, 2=spriteX, 3=spriteY) +function AnimTasks.ShakeMonOrBattleTerrain(t, _vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + t._offset = tonumber(t.data[0]) or 4 + t._delay = math.max(1, tonumber(t.data[1]) or 2) + t._duration = math.max(1, tonumber(t.data[2]) or 16) + t._targetType = tonumber(t.data[3]) or 0 + t._timer = t._delay + t._stage = Anim.stage() + t._curOff = t._offset + end + local st = t._stage + if t._duration > 0 then + t._duration = t._duration - 1 + if t._timer > 0 then + t._timer = t._timer - 1 + else + t._timer = t._delay + t._curOff = -t._curOff + if t._targetType == 0 or t._targetType == 2 then + if st and st.bgSlide then + st.bgSlide.playerOx = t._curOff + st.bgSlide.enemyOx = t._curOff + end + end + end + else + if st and st.bgSlide then + st.bgSlide.playerOx = 0 + st.bgSlide.enemyOx = 0 + end + destroy_task(t) + end end AnimTasks.REGISTRY.ShakeMonOrBattleTerrain = AnimTasks.ShakeMonOrBattleTerrain +AnimTasks.REGISTRY.AnimShakeMonOrBattleTerrain = AnimTasks.ShakeMonOrBattleTerrain AnimTasks.REGISTRY.AnimTask_ShakeMonOrBattleTerrain = AnimTasks.ShakeMonOrBattleTerrain ---- pret AnimTask_CreateSurfWave: creates surging wave of water across the battlefield. +--- pret AnimTask_CreateSurfWave (pokefirered/src/battle_anim_water.c:799): +-- Authentic multi-phase surging tidal wave with GBA scanline displacement, dynamic sine foam crests, and collision impact. function AnimTasks.CreateSurfWave(t, vm) local frame = t.data[14] or 0 t.data[14] = frame + 1 - local totalDur = 40 + local totalDur = 46 local atkSide = vm:attackerSide() local tgtSide = vm:resolveBattlerSide("target") local Anim = require("src.core.game3.battle.anim") local pTgt = Anim.present(tgtSide) + local pAtk = Anim.present(atkSide) - local ax, ay = vm:battlerCenter(atkSide) - local tx, ty = vm:battlerCenter(tgtSide) + local isMuddy = (t.data[0] == 1 or t.data[8] == 1 or (t.name and t.name:find("Muddy"))) + t.data[8] = isMuddy and 1 or 0 + t.z = AnimSprites.Z.GLOBAL_FRONT - -- 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 + -- Initial setup matching GBA AnimTask_CreateSurfWave + if frame == 1 then + if atkSide == "player" then + t.data[0] = -2 -- dx + t.data[1] = 1 -- dy + t.data[10] = 0 -- scrollX + t.data[11] = -48 -- scrollY + else + t.data[0] = 2 + t.data[1] = -1 + t.data[10] = -224 + t.data[11] = 256 end + t._particles = {} end - -- Target shakes when wave hits - if frame >= 18 and frame < 36 and pTgt then + -- Update scroll offsets + t.data[10] = (t.data[10] or 0) + (t.data[0] or -2) + t.data[11] = (t.data[11] or 0) + (t.data[1] or 1) + + -- GBA Alpha Blending Curve (0 -> 14/16 -> 0) + local alpha = 0 + if frame <= 14 then + alpha = (frame / 14) * (14 / 16) + elseif frame <= 34 then + alpha = 14 / 16 + else + alpha = math.max(0, (14 / 16) * (1 - (frame - 34) / 12)) + end + t._alpha = alpha + + -- 1. Attacker lunges slightly forward on wave launch (frames 1..12) + if frame < 12 and pAtk then + local u = frame / 12 + local dir = (atkSide == "player") and 1 or -1 + pAtk.ox = math.floor(math.sin(u * math.pi) * 8 * dir) + elseif frame >= 12 and pAtk and pAtk.ox ~= 0 then + pAtk.ox = 0 + end + + -- 2. Wave impact & collision shudder on target (frames 16..38) + if frame >= 16 and frame < 38 and pTgt then local phase = frame % 4 - local amp = 4 + local amp = math.max(1, math.floor(5 * (1 - (frame - 16) / 22))) pTgt.ox = (phase == 0 or phase == 3) and amp or -amp + pTgt.oy = (phase == 1 or phase == 2) and amp or -amp pTgt.flash = frame - elseif frame >= 36 and pTgt then + elseif frame >= 38 and pTgt then pTgt.ox = 0 + pTgt.oy = 0 pTgt.flash = 0 end + -- 3. Procedural water splash & droplet particles around target on impact + if frame >= 16 and frame <= 34 and frame % 2 == 0 then + local tx, ty = vm:battlerCenter(tgtSide) + t._particles = t._particles or {} + for _ = 1, 3 do + t._particles[#t._particles + 1] = { + x = tx + math.random(-24, 24), + y = ty + math.random(-16, 16), + vx = (math.random() - 0.5) * 3, + vy = -(math.random() * 2.5 + 1.5), + life = 0, + maxLife = math.random(10, 18), + size = math.random(2, 4), + } + end + end + + -- Update active particles + if t._particles then + local alive = {} + for _, p in ipairs(t._particles) do + p.life = p.life + 1 + p.x = p.x + p.vx + p.y = p.y + p.vy + p.vy = p.vy + 0.15 -- gravity + if p.life < p.maxLife then + alive[#alive + 1] = p + end + end + t._particles = alive + end + + -- 4. Draw callback attached directly to task + t.draw = function(task, _vm) + local a = task._alpha or 0 + if a <= 0.01 then return end + if not (love and love.graphics) then return end + + local isMud = (task.data[8] == 1) + local rFill, gFill, bFill = 0.12, 0.52, 0.90 + local rDeep, gDeep, bDeep = 0.05, 0.30, 0.65 + local rCrest, gCrest, bCrest = 0.94, 0.97, 1.00 + local rShimmer, gShimmer, bShimmer = 0.40, 0.75, 1.00 + if isMud then + rFill, gFill, bFill = 0.56, 0.40, 0.22 + rDeep, gDeep, bDeep = 0.36, 0.24, 0.12 + rCrest, gCrest, bCrest = 0.86, 0.78, 0.62 + rShimmer, gShimmer, bShimmer = 0.70, 0.55, 0.35 + end + + local scrollX = task.data[10] or 0 + local isPlr = (atkSide == "player") + local f = task.data[14] or 1 + + -- GBA Scanline Swelling & Full-Screen Tidal Wave Engulfment + -- Wave crest swells and rises to engulf the whole screen (0..112) + local surgeProgress = 0 + if f <= 20 then + surgeProgress = f / 20 -- rising surge (0 -> 1) + elseif f <= 34 then + surgeProgress = 1.0 -- full screen engulfment peak + else + surgeProgress = math.max(0, 1.0 - (f - 34) / 12) -- receding drain (1 -> 0) + end + + -- Swell baseline: Player surge rises from 95 up to -10 (engulfs entire arena); Opponent crashes from 0 down to 112 + local baselineY = 0 + if isPlr then + baselineY = 95 - (surgeProgress * 105) + math.floor(Sin((scrollX * 2) % 256, 4)) + else + baselineY = 15 + (surgeProgress * 85) + math.floor(Sin((scrollX * 2) % 256, 4)) + end + + -- Build smooth wave crest polygon across the full screen width (0..240) + local numSegs = 32 + local stepX = 240 / numSegs + local pts = {} + for i = 0, numSegs do + local sx = i * stepX + local wavePhase1 = (sx * 3 + scrollX * 4) % 256 + local wavePhase2 = (sx * 6 - scrollX * 5) % 256 + local waveAmp = (6 + math.floor(surgeProgress * 8)) + Sin((f * 6) % 256, 3) + local waveH = Sin(wavePhase1, waveAmp) + Sin(wavePhase2, 3) + -- Dynamic surge slope from attacker side across to target + local slopeProgress = math.min(1.0, f / 18) + local slope = isPlr and (((sx / 240) * 20 - 10) * (1 - slopeProgress * 0.5)) + or ((((240 - sx) / 240) * 20 - 10) * (1 - slopeProgress * 0.5)) + local sy = baselineY + waveH - slope + pts[#pts + 1] = { x = sx, y = math.min(112, sy) } + end + + -- 1. Fill deep water body across the entire battle arena (y = 0..112) + local poly = {} + for _, pt in ipairs(pts) do + poly[#poly + 1] = pt.x + poly[#poly + 1] = pt.y + end + poly[#poly + 1] = 240 + poly[#poly + 1] = 112 + poly[#poly + 1] = 0 + poly[#poly + 1] = 112 + + if #poly >= 6 then + love.graphics.setColor(rDeep, gDeep, bDeep, a * 0.75) + love.graphics.polygon("fill", poly) + + -- 2. Mid water surge layer with offset + local midPoly = {} + for _, pt in ipairs(pts) do + midPoly[#midPoly + 1] = pt.x + midPoly[#midPoly + 1] = pt.y + 4 + end + midPoly[#midPoly + 1] = 240 + midPoly[#midPoly + 1] = 112 + midPoly[#midPoly + 1] = 0 + midPoly[#midPoly + 1] = 112 + if #midPoly >= 6 then + love.graphics.setColor(rFill, gFill, bFill, a * 0.85) + love.graphics.polygon("fill", midPoly) + end + end + + -- 3. Shimmering horizontal wave scanline bands + love.graphics.setColor(rShimmer, gShimmer, bShimmer, a * 0.55) + for _, pt in ipairs(pts) do + if pt.x % 16 < stepX then + love.graphics.line(pt.x, pt.y + 8, pt.x + 10, pt.y + 8) + love.graphics.line(pt.x + 4, pt.y + 16, pt.x + 14, pt.y + 16) + love.graphics.line(pt.x + 2, pt.y + 24, pt.x + 12, pt.y + 24) + love.graphics.line(pt.x + 6, pt.y + 36, pt.x + 18, pt.y + 36) + end + end + + -- 4. White foam crest along the leading wave ridge + love.graphics.setColor(rCrest, gCrest, bCrest, a * 0.95) + love.graphics.setLineWidth(2.5) + for i = 1, #pts - 1 do + love.graphics.line(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) + if i % 3 == 0 then + love.graphics.circle("fill", pts[i].x, pts[i].y - 1, 2.0) + end + end + love.graphics.setLineWidth(1) + + -- 5. Splashing spray droplets + if task._particles then + for _, p in ipairs(task._particles) do + local pAlpha = a * (1 - p.life / p.maxLife) + love.graphics.setColor(rCrest, gCrest, bCrest, pAlpha) + love.graphics.circle("fill", p.x, p.y, p.size) + end + end + + love.graphics.setColor(1, 1, 1, 1) + end + if frame >= totalDur then - if pTgt then pTgt.ox = 0 pTgt.flash = 0 end + if pTgt then pTgt.ox = 0; pTgt.oy = 0; pTgt.flash = 0 end + if pAtk then pAtk.ox = 0 end destroy_task(t) end end @@ -371,13 +998,3645 @@ end AnimTasks.REGISTRY.CreateSurfWave = AnimTasks.CreateSurfWave AnimTasks.REGISTRY.AnimTask_CreateSurfWave = AnimTasks.CreateSurfWave -function AnimTasks.SetGrayscaleOrOriginalPal(t, _vm) +--- pret AnimTask_WaterSport / AnimTask_Splash: water droplets and splashing fountain. +function AnimTasks.WaterSport(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local side = vm:attackerSide() + local cx, cy = vm:battlerCenter(side) + local pack = vm._pack + + if frame < 16 and frame % 3 == 0 then + local imgMeta = pack and pack.tags and (pack.tags["WATER_DROPLET"] or pack.tags["BUBBLE"] or pack.tags["WATER_ORB"]) + if imgMeta and imgMeta.image then + for _ = 1, 3 do + local vx = math.random(-25, 25) / 10 + local vy = -math.random(30, 50) / 10 + local spr = AnimSprites.acquire({ + x = cx + math.random(-12, 12), + y = cy + math.random(-8, 8), + z = AnimSprites.Z.FRONT, + image = imgMeta.image, + w = imgMeta.frameW or 16, + h = imgMeta.frameH or 16, + tag = "WATER_DROPLET", + callback = function(s) + s.data[0] = (s.data[0] or 0) + 1 + local step = s.data[0] + s.ox = math.floor(vx * step) + s.oy = math.floor(vy * step + 0.5 * 0.35 * step^2) + s.alpha = math.max(0, 1 - step / 20) + if step >= 20 then AnimSprites.release(s) end + end, + }) + if spr then + spr._baseW = imgMeta.frameW or 16 + spr._baseH = imgMeta.frameH or 16 + end + end + end + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.WaterSport = AnimTasks.WaterSport +AnimTasks.REGISTRY.AnimTask_WaterSport = AnimTasks.WaterSport +AnimTasks.REGISTRY.Splash = AnimTasks.WaterSport +AnimTasks.REGISTRY.AnimTask_Splash = AnimTasks.WaterSport + +--- pret AnimTask_InvertScreenColor / AnimTask_HardwarePaletteFade / AnimTask_FadeScreenToWhite: GLSL screen effect shaders. +function AnimTasks.InvertScreenColor(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(8, tonumber(t.data[0]) or 16) + local Anim = require("src.core.game3.battle.anim") + + -- Flash screen negative every 4 frames (2 frames on, 2 frames off) + if (frame % 4 < 2) and frame < dur then + Anim.setScreenEffect({ type = "invert", coeff = 1.0 }) + else + Anim.setScreenEffect(nil) + end + + if frame >= dur then + Anim.setScreenEffect(nil) + destroy_task(t) + end +end + +function AnimTasks.FlashAnimTagWithColor(t, _vm) + if not t._inited then + t._inited = true + local tag = t.data[0] or 0 + local delay = math.max(1, tonumber(t.data[1]) or 2) + local numFlashes = math.max(1, tonumber(t.data[2]) or 1) + local color = t.data[3] or 0 + local coeff = tonumber(t.data[4]) or 16 + t._tag = tag + t._delay = delay + t._flashesLeft = numFlashes + t._color = color + t._coeff = coeff + t._timer = delay + end + + t._timer = t._timer - 1 + if t._timer <= 0 then + t._timer = t._delay + t._flashesLeft = t._flashesLeft - 1 + if t._flashesLeft <= 0 then + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.InvertScreenColor = AnimTasks.InvertScreenColor +AnimTasks.REGISTRY.AnimTask_InvertScreenColor = AnimTasks.InvertScreenColor +AnimTasks.REGISTRY.FlashAnimTagWithColor = AnimTasks.FlashAnimTagWithColor +AnimTasks.REGISTRY.AnimTask_FlashAnimTagWithColor = AnimTasks.FlashAnimTagWithColor + +function AnimTasks.FadeScreenToWhite(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(10, tonumber(t.data[0]) or 20) + local Anim = require("src.core.game3.battle.anim") + + local u = math.sin((frame / dur) * math.pi) + Anim.setScreenEffect({ type = "fade_white", coeff = u }) + + if frame >= dur then + Anim.setScreenEffect(nil) + destroy_task(t) + end +end + +AnimTasks.REGISTRY.FadeScreenToWhite = AnimTasks.FadeScreenToWhite +AnimTasks.REGISTRY.AnimTask_FadeScreenToWhite = AnimTasks.FadeScreenToWhite + +function AnimTasks.HardwarePaletteFade(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local delay = math.max(1, tonumber(t.data[1]) or 2) + local startCoeff = tonumber(t.data[2]) or 0 + local targetCoeff = tonumber(t.data[3]) or 10 + local totalSteps = math.max(1, math.abs(targetCoeff - startCoeff)) + local dur = math.max(6, delay * totalSteps) + local Anim = require("src.core.game3.battle.anim") + + local progress = math.min(1.0, frame / dur) + local curCoeff = (startCoeff + (targetCoeff - startCoeff) * progress) / 16 + Anim.setScreenEffect({ type = "fade_black", coeff = curCoeff }) + + if frame >= dur then + if targetCoeff == 0 then + Anim.setScreenEffect(nil) + end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.HardwarePaletteFade = AnimTasks.HardwarePaletteFade +AnimTasks.REGISTRY.AnimTask_HardwarePaletteFade = AnimTasks.HardwarePaletteFade + +--- pret AnimTask_HorizontalShake / AnimTask_ShakeBattleTerrain (pokefirered/src/battle_anim_ground.c:555) +-- arg 0: what to shake (0-3 battler, 4 all battlers, 5 terrain); arg 1: shake intensity; arg 2: length of time +function AnimTasks.HorizontalShake(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local intensity = tonumber(t.data[1]) or 0 + if intensity == 0 then + intensity = math.floor((vm.movePower or 80) / 10) + 3 + else + intensity = intensity + 3 + end + t._intensity = intensity + t._curOffset = intensity + t._maxTime = math.max(1, tonumber(t.data[2]) or 16) + t._which = t.data[0] + t._state = 0 + t._delay = 0 + t._timer = 0 + t._stage = Anim.stage() + end + local st = t._stage + if t._state == 0 then + t._delay = t._delay + 1 + if t._delay > 1 then + t._delay = 0 + local off = (t._timer % 2 == 0) and t._intensity or -t._intensity + if st and st.bgSlide then + st.bgSlide.playerOx = off + st.bgSlide.enemyOx = off + end + t._timer = t._timer + 1 + if t._timer >= t._maxTime then + t._timer = 0 + t._curOffset = t._curOffset - 1 + t._state = 1 + end + end + elseif t._state == 1 then + t._delay = t._delay + 1 + if t._delay > 1 then + t._delay = 0 + local off = (t._timer % 2 == 0) and t._curOffset or -t._curOffset + if st and st.bgSlide then + st.bgSlide.playerOx = off + st.bgSlide.enemyOx = off + end + t._timer = t._timer + 1 + if t._timer >= 4 then + t._timer = 0 + t._curOffset = t._curOffset - 1 + if t._curOffset <= 0 then + t._state = 2 + end + end + end + elseif t._state == 2 then + if st and st.bgSlide then + st.bgSlide.playerOx = 0 + st.bgSlide.enemyOx = 0 + end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.HorizontalShake = AnimTasks.HorizontalShake +AnimTasks.REGISTRY.AnimTask_HorizontalShake = AnimTasks.HorizontalShake +AnimTasks.REGISTRY.ShakeBattleTerrain = AnimTasks.HorizontalShake +AnimTasks.REGISTRY.AnimTask_ShakeBattleTerrain = AnimTasks.HorizontalShake +AnimTasks.REGISTRY.ShakeBattlers = AnimTasks.HorizontalShake +AnimTasks.REGISTRY.ShakeTerrain = AnimTasks.HorizontalShake + +--- pret AnimTask_ShakeTargetBasedOnMovePowerOrDmg (pokefirered/src/battle_anim_mon_movement.c:872) +-- arg 0: 0=power, 1=dmg; arg 1: frame delay; arg 2: num shakes; arg 3: shakeX; arg 4: shakeY +function AnimTasks.ShakeTargetBasedOnMovePowerOrDmg(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local mode = tonumber(t.data[0]) or 0 + local val = (mode == 0) and (vm.movePower or 60) or (vm.moveDmg or 60) + local intensity = math.max(1, math.min(16, math.floor(val / 12))) + t._half = math.floor(intensity / 2) + t._plusOdd = t._half + (intensity % 2) + t._intensity = intensity + t._delay = math.max(1, tonumber(t.data[1]) or 2) + t._numShakes = math.max(1, tonumber(t.data[2]) or 4) + t._shakeX = (tonumber(t.data[3]) or 0) ~= 0 + t._shakeY = (tonumber(t.data[4]) or 0) ~= 0 + t._side = vm:targetSide() + t._p = Anim.present(t._side) + if not t._p then + destroy_task(t) + return + end + t._origX = t._p.ox or 0 + t._origY = t._p.oy or 0 + t._phase = 0 + t._timer = 0 + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._timer = t._timer + 1 + if t._timer > t._delay then + t._timer = 0 + t._phase = (t._phase + 1) % 2 + if t._shakeX then + if t._phase == 1 then + p.ox = t._origX + t._plusOdd + else + p.ox = t._origX - t._half + end + end + if t._shakeY then + if t._phase == 1 then + p.oy = t._intensity + else + p.oy = 0 + end + end + t._numShakes = t._numShakes - 1 + if t._numShakes <= 0 then + p.ox = 0 + p.oy = 0 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.ShakeTargetBasedOnMovePowerOrDmg = AnimTasks.ShakeTargetBasedOnMovePowerOrDmg +AnimTasks.REGISTRY.AnimTask_ShakeTargetBasedOnMovePowerOrDmg = AnimTasks.ShakeTargetBasedOnMovePowerOrDmg + +--- pret AnimTask_ShakeTargetInPattern (pokefirered/src/battle_anim_fire.c:1254) +-- arg 0: maxShakes; arg 1: shakeOffset; arg 2: isVertical; arg 3: patternId +local SHAKE_PATTERN_0 = { -1, -1, 0, 1, 1, 0, 0, -1, -1, 1 } +local SHAKE_PATTERN_1 = { -1, 0, 1, 0, -1, 1, 0, -1, 0, 1 } + +function AnimTasks.ShakeTargetInPattern(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + t._maxShakes = math.max(1, tonumber(t.data[0]) or 10) + t._shakeOffset = tonumber(t.data[1]) or 4 + t._isVertical = (tonumber(t.data[2]) or 0) ~= 0 + t._patternId = tonumber(t.data[3]) or 0 + t._shakeNum = 0 + t._side = vm:targetSide() + t._p = Anim.present(t._side) + if not t._p then + destroy_task(t) + return + end + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._shakeNum = t._shakeNum + 1 + local pat = (t._patternId == 0) and SHAKE_PATTERN_0 or SHAKE_PATTERN_1 + local dir = pat[(t._shakeNum % #pat) + 1] or 0 + if t._isVertical then + p.oy = math.abs(t._shakeOffset * dir) + else + p.ox = t._shakeOffset * dir + end + if t._shakeNum >= t._maxShakes then + p.ox = 0 + p.oy = 0 + destroy_task(t) + end +end + +AnimTasks.REGISTRY.ShakeTargetInPattern = AnimTasks.ShakeTargetInPattern +AnimTasks.REGISTRY.AnimTask_ShakeTargetInPattern = AnimTasks.ShakeTargetInPattern + +--- RGB555 unpacker helper (pokefirered RGB_*) +local function unpackRgb555(col) + if type(col) == "table" then + local r = (col[1] or 0) > 1 and (col[1] / 31) or (col[1] or 0) + local g = (col[2] or 0) > 1 and (col[2] / 31) or (col[2] or 0) + local b = (col[3] or 0) > 1 and (col[3] / 31) or (col[3] or 0) + return r, g, b + end + local c = tonumber(col) or 0 + local r = (c % 32) / 31 + local g = (math.floor(c / 32) % 32) / 31 + local b = (math.floor(c / 1024) % 32) / 31 + return r, g, b +end + +--- pret AnimTask_BlendBattleAnimPal (pokefirered/src/battle_anim_utility_funcs.c:53) +-- arg 0: pal selector bitfield, arg 1: delay, arg 2: startCoeff, arg 3: targetCoeff, arg 4: color +function AnimTasks.BlendBattleAnimPal(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local palMask = tonumber(t.data[0]) or 0 + local delay = math.max(0, tonumber(t.data[1]) or 0) + local startCoeff = math.max(0, math.min(16, tonumber(t.data[2]) or 0)) + local targetCoeff = math.max(0, math.min(16, tonumber(t.data[3]) or 0)) + local color = t.data[4] or 0 + local r, g, b = unpackRgb555(color) + t._palMask = palMask + t._delay = delay + t._currCoeff = startCoeff + t._targetCoeff = targetCoeff + t._color = { r, g, b } + t._timer = 0 + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + local coeff = t._currCoeff / 16 + local r, g, b = t._color[1], t._color[2], t._color[3] + local palMask = t._palMask + + -- Bit 0: Background + if (palMask % 2) == 1 then + local fxType = "custom_blend" + if r == 1 and g == 1 and b == 1 then fxType = "fade_white" + elseif r == 0 and g == 0 and b == 0 then fxType = "fade_black" end + if coeff > 0 then + Anim.setScreenEffect({ type = fxType, coeff = coeff, targetColor = { r, g, b } }) + else + Anim.setScreenEffect(nil) + end + end + + -- Battler palettes: bit 1 (atk), bit 2 (tgt), bit 7 (player), bit 9 (enemy) + local atkPresent = Anim.present(vm:attackerSide()) + local tgtPresent = Anim.present(vm:targetSide()) + local isAtkSelected = (math.floor(palMask / 2) % 2 == 1) + local isTgtSelected = (math.floor(palMask / 4) % 2 == 1) + if (math.floor(palMask / 128) % 2 == 1) then -- player + if vm:attackerSide() == "player" then isAtkSelected = true else isTgtSelected = true end + end + if (math.floor(palMask / 512) % 2 == 1) then -- enemy + if vm:attackerSide() == "enemy" then isAtkSelected = true else isTgtSelected = true end + end + + if isAtkSelected and atkPresent then + atkPresent.blendColor = { r, g, b } + atkPresent.blendCoeff = coeff + atkPresent.darken = (r == 0 and g == 0 and b == 0) and coeff or 0 + atkPresent.flash = (r == 1 and g == 1 and b == 1) and (coeff > 0 and 1 or 0) or 0 + end + if isTgtSelected and tgtPresent then + tgtPresent.blendColor = { r, g, b } + tgtPresent.blendCoeff = coeff + tgtPresent.darken = (r == 0 and g == 0 and b == 0) and coeff or 0 + tgtPresent.flash = (r == 1 and g == 1 and b == 1) and (coeff > 0 and 1 or 0) or 0 + end + + local finished = (t._currCoeff == t._targetCoeff) + if t._currCoeff < t._targetCoeff then + t._currCoeff = t._currCoeff + 1 + elseif t._currCoeff > t._targetCoeff then + t._currCoeff = t._currCoeff - 1 + end + + if finished then + if t._targetCoeff == 0 then + if (palMask % 2) == 1 then Anim.setScreenEffect(nil) end + if isAtkSelected and atkPresent then + atkPresent.blendCoeff = 0 + atkPresent.darken = 0 + atkPresent.flash = 0 + end + if isTgtSelected and tgtPresent then + tgtPresent.blendCoeff = 0 + tgtPresent.darken = 0 + tgtPresent.flash = 0 + end + end + destroy_task(t) + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.BlendBattleAnimPal = AnimTasks.BlendBattleAnimPal +AnimTasks.REGISTRY.AnimTask_BlendBattleAnimPal = AnimTasks.BlendBattleAnimPal + +--- pret AnimTask_BlendColorCycle / BlendColorCycleExclude / BlendColorCycleByTag (pokefirered/src/battle_anim_normal.c:424, 484, 559) +-- arg 0: pal selector, arg 1: delay, arg 2: numBlends, arg 3: initialBlend, arg 4: targetBlend, arg 5: color +function AnimTasks.BlendColorCycle(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local palMask = tonumber(t.data[0]) or 0 + local delay = math.max(0, tonumber(t.data[1]) or 0) + local numBlends = math.max(1, tonumber(t.data[2]) or 1) + local initialBlend = tonumber(t.data[3]) or 0 + local targetBlend = tonumber(t.data[4]) or 16 + local color = t.data[5] or 0 + local r, g, b = unpackRgb555(color) + t._palMask = palMask + t._delay = delay + t._numBlends = numBlends + t._initialBlend = initialBlend + t._targetBlend = targetBlend + t._color = { r, g, b } + t._currCoeff = initialBlend + t._targetCoeff = targetBlend + t._timer = 0 + t._restore = false + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + local coeff = t._currCoeff / 16 + local r, g, b = t._color[1], t._color[2], t._color[3] + local palMask = t._palMask + + -- Screen / BG + if (palMask % 2) == 1 then + local fxType = "custom_blend" + if r == 1 and g == 1 and b == 1 then fxType = "fade_white" + elseif r == 0 and g == 0 and b == 0 then fxType = "fade_black" end + if coeff > 0 then + Anim.setScreenEffect({ type = fxType, coeff = coeff, targetColor = { r, g, b } }) + else + Anim.setScreenEffect(nil) + end + end + + -- Battlers + local atkPresent = Anim.present(vm:attackerSide()) + local tgtPresent = Anim.present(vm:targetSide()) + local isAtkSelected = (math.floor(palMask / 2) % 2 == 1) + local isTgtSelected = (math.floor(palMask / 4) % 2 == 1) + if isAtkSelected and atkPresent then + atkPresent.blendColor = { r, g, b } + atkPresent.blendCoeff = coeff + atkPresent.darken = (r == 0 and g == 0 and b == 0) and coeff or 0 + atkPresent.flash = (r == 1 and g == 1 and b == 1) and (coeff > 0 and 1 or 0) or 0 + end + if isTgtSelected and tgtPresent then + tgtPresent.blendColor = { r, g, b } + tgtPresent.blendCoeff = coeff + tgtPresent.darken = (r == 0 and g == 0 and b == 0) and coeff or 0 + tgtPresent.flash = (r == 1 and g == 1 and b == 1) and (coeff > 0 and 1 or 0) or 0 + end + + if t._currCoeff < t._targetCoeff then + t._currCoeff = t._currCoeff + 1 + elseif t._currCoeff > t._targetCoeff then + t._currCoeff = t._currCoeff - 1 + else + t._numBlends = t._numBlends - 1 + if t._numBlends > 0 then + t._restore = not t._restore + if t._restore then + t._targetCoeff = (t._numBlends == 1) and 0 or t._initialBlend + else + t._targetCoeff = t._targetBlend + end + else + if (palMask % 2) == 1 then Anim.setScreenEffect(nil) end + if isAtkSelected and atkPresent then + atkPresent.blendCoeff = 0 + atkPresent.darken = 0 + atkPresent.flash = 0 + end + if isTgtSelected and tgtPresent then + tgtPresent.blendCoeff = 0 + tgtPresent.darken = 0 + tgtPresent.flash = 0 + end + destroy_task(t) + end + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.BlendColorCycle = AnimTasks.BlendColorCycle +AnimTasks.REGISTRY.AnimTask_BlendColorCycle = AnimTasks.BlendColorCycle +AnimTasks.REGISTRY.BlendColorCycleExclude = AnimTasks.BlendColorCycle +AnimTasks.REGISTRY.AnimTask_BlendColorCycleExclude = AnimTasks.BlendColorCycle +AnimTasks.REGISTRY.BlendColorCycleByTag = AnimTasks.BlendColorCycle +AnimTasks.REGISTRY.AnimTask_BlendColorCycleByTag = AnimTasks.BlendColorCycle + +--- pret AnimComplexPaletteBlend (pokefirered/src/battle_anim_normal.c:339) +-- arg 0: palMask, arg 1: delay, arg 2: numCycles, arg 3: color1, arg 4: coeff1, arg 5: color2, arg 6: coeff2 +function AnimTasks.ComplexPaletteBlend(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local palMask = tonumber(t.data[0]) or 0 + local delay = math.max(1, tonumber(t.data[1]) or 1) + local numCycles = math.max(1, tonumber(t.data[2]) or 1) + local color1 = t.data[3] or 0 + local coeff1 = (tonumber(t.data[4]) or 16) / 16 + local color2 = t.data[5] or 0 + local coeff2 = (tonumber(t.data[6]) or 0) / 16 + local r1, g1, b1 = unpackRgb555(color1) + local r2, g2, b2 = unpackRgb555(color2) + t._palMask = palMask + t._delay = delay + t._cyclesLeft = numCycles + t._c1 = { r1, g1, b1 } + t._k1 = coeff1 + t._c2 = { r2, g2, b2 } + t._k2 = coeff2 + t._phase = 0 + t._timer = delay + + -- Initial blend 1 + local isAtk = (math.floor(palMask / 2) % 2 == 1) + local isTgt = (math.floor(palMask / 4) % 2 == 1) + local atkP = Anim.present(vm:attackerSide()) + local tgtP = Anim.present(vm:targetSide()) + if isAtk and atkP then + atkP.blendColor = t._c1 + atkP.blendCoeff = t._k1 + atkP.darken = (r1 == 0 and g1 == 0 and b1 == 0) and t._k1 or 0 + atkP.flash = (r1 == 1 and g1 == 1 and b1 == 1) and (t._k1 > 0 and 1 or 0) or 0 + end + if isTgt and tgtP then + tgtP.blendColor = t._c1 + tgtP.blendCoeff = t._k1 + tgtP.darken = (r1 == 0 and g1 == 0 and b1 == 0) and t._k1 or 0 + tgtP.flash = (r1 == 1 and g1 == 1 and b1 == 1) and (t._k1 > 0 and 1 or 0) or 0 + end + end + + t._timer = t._timer - 1 + if t._timer <= 0 then + t._timer = t._delay + if t._cyclesLeft <= 0 then + local palMask = t._palMask + local isAtk = (math.floor(palMask / 2) % 2 == 1) + local isTgt = (math.floor(palMask / 4) % 2 == 1) + local atkP = Anim.present(vm:attackerSide()) + local tgtP = Anim.present(vm:targetSide()) + if (palMask % 2) == 1 then Anim.setScreenEffect(nil) end + if isAtk and atkP then atkP.blendCoeff = 0; atkP.darken = 0; atkP.flash = 0 end + if isTgt and tgtP then tgtP.blendCoeff = 0; tgtP.darken = 0; tgtP.flash = 0 end + destroy_task(t) + else + t._phase = 1 - t._phase + local c = (t._phase == 0) and t._c1 or t._c2 + local k = (t._phase == 0) and t._k1 or t._k2 + local palMask = t._palMask + local isAtk = (math.floor(palMask / 2) % 2 == 1) + local isTgt = (math.floor(palMask / 4) % 2 == 1) + local atkP = Anim.present(vm:attackerSide()) + local tgtP = Anim.present(vm:targetSide()) + if isAtk and atkP then + atkP.blendColor = c + atkP.blendCoeff = k + atkP.darken = (c[1] == 0 and c[2] == 0 and c[3] == 0) and k or 0 + atkP.flash = (c[1] == 1 and c[2] == 1 and c[3] == 1) and (k > 0 and 1 or 0) or 0 + end + if isTgt and tgtP then + tgtP.blendColor = c + tgtP.blendCoeff = k + tgtP.darken = (c[1] == 0 and c[2] == 0 and c[3] == 0) and k or 0 + tgtP.flash = (c[1] == 1 and c[2] == 1 and c[3] == 1) and (k > 0 and 1 or 0) or 0 + end + t._cyclesLeft = t._cyclesLeft - 1 + end + end +end + +AnimTasks.REGISTRY.ComplexPaletteBlend = AnimTasks.ComplexPaletteBlend +AnimTasks.REGISTRY.AnimComplexPaletteBlend = AnimTasks.ComplexPaletteBlend + +--- pret AnimTask_BlendBattleAnimPalExclude (pokefirered/src/battle_anim_utility_funcs.c:75) +function AnimTasks.BlendBattleAnimPalExclude(t, vm) + local cmd = tonumber(t.data[0]) or 0 + -- Map exclude cmd to palMask: + -- 0: Not attacker (blend target and BG -> bit 0 | bit 2 = 5) + -- 1: Not target (blend attacker and BG -> bit 0 | bit 1 = 3) + -- 2: Not attacker nor BG (blend target only -> bit 2 = 4) + -- 3: Not target nor BG (blend attacker only -> bit 1 = 2) + -- 4: Neither attacker nor target (blend BG only -> bit 0 = 1) + -- 5: Blend all (bit 0 | bit 1 | bit 2 = 7) + -- 6: Neither bg nor attacker partner (blend target = 4) + -- 7: Neither bg nor target partner (blend attacker = 2) + local maskMap = { [0] = 5, [1] = 3, [2] = 4, [3] = 2, [4] = 1, [5] = 7, [6] = 4, [7] = 2 } + local palMask = maskMap[cmd] or 7 + t.data[0] = palMask + AnimTasks.BlendBattleAnimPal(t, vm) +end + +AnimTasks.REGISTRY.BlendBattleAnimPalExclude = AnimTasks.BlendBattleAnimPalExclude +AnimTasks.REGISTRY.AnimTask_BlendBattleAnimPalExclude = AnimTasks.BlendBattleAnimPalExclude + +--- pret AnimTask_SetCamouflageBlend (pokefirered/src/battle_anim_utility_funcs.c:123) +function AnimTasks.SetCamouflageBlend(t, vm) + local TERRAIN_COLORS = { + grass = 0x0718, -- RGB(12, 24, 2) + long_grass = 0x05E0, -- RGB(0, 15, 2) + sand = 0x2F1E, -- RGB(30, 24, 11) + water = 0x7ECA, -- RGB(11, 22, 31) + cave = 0x0D2E, -- RGB(14, 9, 3) + building = 0x7FFF, -- RGB(31, 31, 31) + plain = 0x7FFF, -- RGB(31, 31, 31) + } + t.data[4] = TERRAIN_COLORS.plain + AnimTasks.BlendBattleAnimPal(t, vm) +end + +AnimTasks.REGISTRY.SetCamouflageBlend = AnimTasks.SetCamouflageBlend +AnimTasks.REGISTRY.AnimTask_SetCamouflageBlend = AnimTasks.SetCamouflageBlend + +--- pret AnimTask_BlendParticle (pokefirered/src/battle_anim_utility_funcs.c:163) +function AnimTasks.BlendParticle(t, vm) + t.data[0] = 1 -- BG / particle palette + AnimTasks.BlendBattleAnimPal(t, vm) +end + +AnimTasks.REGISTRY.BlendParticle = AnimTasks.BlendParticle +AnimTasks.REGISTRY.AnimTask_BlendParticle = AnimTasks.BlendParticle + +--- pret AnimTask_BlendMonInAndOut (pokefirered/src/battle_anim_mons.c:1603) +-- arg 0: battler, arg 1: color, arg 2: targetCoeff, arg 3: delay, arg 4: repeats +function AnimTasks.BlendMonInAndOut(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local whichMon = t.data[0] or 0 + local color = t.data[1] or 0 + local targetCoeff = tonumber(t.data[2]) or 16 + local delay = math.max(0, tonumber(t.data[3]) or 0) + local repeats = math.max(1, tonumber(t.data[4]) or 1) + local r, g, b = unpackRgb555(color) + local side = vm:resolveBattlerSide(whichMon) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._targetCoeff = targetCoeff + t._delay = delay + t._repeats = repeats + t._color = { r, g, b } + t._currCoeff = 0 + t._dir = 0 -- 0=in, 1=out + t._timer = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + local r, g, b = t._color[1], t._color[2], t._color[3] + local coeff = t._currCoeff / 16 + p.blendColor = { r, g, b } + p.blendCoeff = coeff + p.darken = (r == 0 and g == 0 and b == 0) and coeff or 0 + p.flash = (r == 1 and g == 1 and b == 1) and (coeff > 0 and 1 or 0) or 0 + + if t._dir == 0 then + t._currCoeff = t._currCoeff + 1 + if t._currCoeff >= t._targetCoeff then + t._dir = 1 + end + else + t._currCoeff = t._currCoeff - 1 + if t._currCoeff <= 0 then + t._repeats = t._repeats - 1 + if t._repeats > 0 then + t._dir = 0 + else + p.blendCoeff = 0 + p.darken = 0 + p.flash = 0 + destroy_task(t) + end + end + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.BlendMonInAndOut = AnimTasks.BlendMonInAndOut +AnimTasks.REGISTRY.AnimTask_BlendMonInAndOut = AnimTasks.BlendMonInAndOut +AnimTasks.REGISTRY.BlendPalInAndOutByTag = AnimTasks.BlendMonInAndOut +AnimTasks.REGISTRY.AnimTask_BlendPalInAndOutByTag = AnimTasks.BlendMonInAndOut +AnimTasks.REGISTRY.MetallicShine = AnimTasks.BlendMonInAndOut +AnimTasks.REGISTRY.AnimTask_MetallicShine = AnimTasks.BlendMonInAndOut + +--- pret AnimTask_TraceMonBlended (pokefirered/src/battle_anim_utility_funcs.c:230) +-- arg 0: battler, arg 1: interval, arg 2: lifetime, arg 3: count +function AnimTasks.TraceMonBlended(t, vm) + local Anim = require("src.core.game3.battle.anim") + local AnimSprites = require("src.core.game3.battle.anim_sprites") + if not t._inited then + t._inited = true + local whichMon = t.data[0] or 0 + local interval = math.max(1, tonumber(t.data[1]) or 1) + local lifetime = math.max(1, tonumber(t.data[2]) or 5) + local count = math.max(1, tonumber(t.data[3]) or 3) + local side = vm:resolveBattlerSide(whichMon) + t._side = side + t._interval = interval + t._lifetime = lifetime + t._count = count + t._timer = 0 + t._activeAfterimages = 0 + end + + if t._count > 0 then + if (t._timer or 0) <= 0 then + t._timer = t._interval + t._count = t._count - 1 + local cx, cy = Anim.battlerCenter(t._side) + local pres = Anim.present(t._side) + local s = AnimSprites.acquire({ + x = cx, + y = cy, + z = (pres and pres.z or 100) - 5, + alpha = 0.5, + priority = 1, + callback = function(sprite) + sprite.data[0] = (sprite.data[0] or 0) + 1 + if sprite.data[0] >= t._lifetime then + t._activeAfterimages = math.max(0, t._activeAfterimages - 1) + AnimSprites.release(sprite) + end + end, + }) + if s then + t._activeAfterimages = t._activeAfterimages + 1 + end + else + t._timer = t._timer - 1 + end + elseif t._activeAfterimages <= 0 then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.TraceMonBlended = AnimTasks.TraceMonBlended +AnimTasks.REGISTRY.AnimTask_TraceMonBlended = AnimTasks.TraceMonBlended + +--- pret AnimTask_AttackerFadeToInvisible / AttackerFadeFromInvisible (pokefirered/src/battle_anim_dark.c:187, 226) +function AnimTasks.AttackerFadeToInvisible(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local delay = math.max(0, tonumber(t.data[0]) or 0) + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._delay = delay + t._timer = delay + t._step = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + t._step = t._step + 1 + p.alpha = math.max(0, 1 - (t._step / 16)) + if t._step >= 16 then + p.alpha = 0 + p.visible = false + destroy_task(t) + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.AttackerFadeToInvisible = AnimTasks.AttackerFadeToInvisible +AnimTasks.REGISTRY.AnimTask_AttackerFadeToInvisible = AnimTasks.AttackerFadeToInvisible + +function AnimTasks.AttackerFadeFromInvisible(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local delay = math.max(0, tonumber(t.data[0]) or 0) + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + p = t._p + p.visible = true + t._delay = delay + t._timer = delay + t._step = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + t._step = t._step + 1 + p.alpha = math.min(1, t._step / 16) + if t._step >= 16 then + p.alpha = 1 + destroy_task(t) + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.AttackerFadeFromInvisible = AnimTasks.AttackerFadeFromInvisible +AnimTasks.REGISTRY.AnimTask_AttackerFadeFromInvisible = AnimTasks.AttackerFadeFromInvisible + +--- pret AnimTask_HardwarePaletteFade (pokefirered/src/battle_anim_utility_funcs.c:213) +function AnimTasks.HardwarePaletteFade(t, vm) + -- arg 0: selectedPalettes, arg 1: delay, arg 2: startCoeff, arg 3: endCoeff, arg 4: color + AnimTasks.BlendBattleAnimPal(t, vm) +end + +AnimTasks.REGISTRY.HardwarePaletteFade = AnimTasks.HardwarePaletteFade +AnimTasks.REGISTRY.AnimTask_HardwarePaletteFade = AnimTasks.HardwarePaletteFade + +--- pret AnimSimplePaletteBlend (pokefirered/src/battle_anim_normal.c:302) +-- arg 0: selectedPalettes, arg 1: delay, arg 2: startCoeff, arg 3: endCoeff, arg 4: color +AnimTasks.REGISTRY.SimplePaletteBlend = AnimTasks.BlendBattleAnimPal +AnimTasks.REGISTRY.AnimSimplePaletteBlend = AnimTasks.BlendBattleAnimPal +AnimTasks.REGISTRY.ComplexPaletteBlend = AnimTasks.ComplexPaletteBlend +AnimTasks.REGISTRY.AnimComplexPaletteBlend = AnimTasks.ComplexPaletteBlend + +--- pret AnimTask_Flash (pokefirered/src/battle_anim_utility_funcs.c:583) +function AnimTasks.Flash(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local palMask = tonumber(t.data[0]) or 1 + local delay = math.max(0, tonumber(t.data[1]) or 1) + local numFlashes = math.max(1, tonumber(t.data[2]) or 1) + local color = t.data[3] or 0x7FFF + local r, g, b = unpackRgb555(color) + t._palMask = palMask + t._delay = delay + t._numFlashes = numFlashes + t._color = { r, g, b } + t._timer = 0 + t._state = 0 + end + + if (t._timer or 0) <= 0 then + t._timer = t._delay + t._state = 1 - t._state + local coeff = (t._state == 1) and 1 or 0 + local r, g, b = t._color[1], t._color[2], t._color[3] + if (t._palMask % 2) == 1 then + if coeff > 0 then + Anim.setScreenEffect({ type = (r==1 and g==1 and b==1) and "fade_white" or "fade_black", coeff = coeff, targetColor = { r, g, b } }) + else + Anim.setScreenEffect(nil) + end + end + if t._state == 0 then + t._numFlashes = t._numFlashes - 1 + if t._numFlashes <= 0 then + Anim.setScreenEffect(nil) + destroy_task(t) + end + end + else + t._timer = t._timer - 1 + end +end + +AnimTasks.REGISTRY.Flash = AnimTasks.Flash +AnimTasks.REGISTRY.AnimTask_Flash = AnimTasks.Flash + +--- pret AnimTask_BlendNonAttackerPalettes (pokefirered/src/battle_anim_utility_funcs.c:652) +function AnimTasks.BlendNonAttackerPalettes(t, vm) + t.data[0] = 5 -- target & BG + AnimTasks.BlendBattleAnimPal(t, vm) +end + +AnimTasks.REGISTRY.BlendNonAttackerPalettes = AnimTasks.BlendNonAttackerPalettes +AnimTasks.REGISTRY.AnimTask_BlendNonAttackerPalettes = AnimTasks.BlendNonAttackerPalettes + +--- pret AnimTask_FacadeColorBlend (pokefirered/src/battle_anim_effects_3.c:3802) +local FACADE_COLORS = { + 0x76FF, 0x6EBE, 0x667D, 0x5E3C, 0x55FB, 0x4D9A, 0x4559, 0x3D18, + 0x34D7, 0x2C96, 0x2455, 0x1C14, 0x13D3, 0x0B92, 0x0351, 0x0BD2, + 0x1433, 0x1C94, 0x24F5, 0x2D56, 0x35B7, 0x3E18, 0x4679, 0x4ED9 +} + +function AnimTasks.FacadeColorBlend(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local whichMon = t.data[0] or 0 + local duration = math.max(1, tonumber(t.data[1]) or 24) + local side = vm:resolveBattlerSide(whichMon) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._duration = duration + t._colorIdx = 1 + t._timer = duration + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if t._timer > 0 then + local c = FACADE_COLORS[t._colorIdx] or 0 + local r, g, b = unpackRgb555(c) + p.blendColor = { r, g, b } + p.blendCoeff = 0.5 + t._colorIdx = (t._colorIdx % #FACADE_COLORS) + 1 + t._timer = t._timer - 1 + else + p.blendCoeff = 0 + destroy_task(t) + end +end + +AnimTasks.REGISTRY.FacadeColorBlend = AnimTasks.FacadeColorBlend +AnimTasks.REGISTRY.AnimTask_FacadeColorBlend = AnimTasks.FacadeColorBlend + +--- pret AnimTask_CycleMagicalLeafPal (pokefirered/src/battle_anim_effects_1.c:3668) +local MAGICAL_LEAF_COLORS = { + 0x001F, 0x03E0, 0x7C00, 0x03FF, 0x7C1F, 0x7FE0, 0x7FFF +} + +function AnimTasks.CycleMagicalLeafPal(t, vm) + if not t._inited then + t._inited = true + t._colorIdx = 1 + t._coeff = 0 + t._timer = 0 + end + + t._coeff = t._coeff + 1 + if t._coeff >= 17 then + t._coeff = 0 + t._colorIdx = (t._colorIdx % #MAGICAL_LEAF_COLORS) + 1 + end + + if tonumber(t.data[7]) == -1 then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.CycleMagicalLeafPal = AnimTasks.CycleMagicalLeafPal +AnimTasks.REGISTRY.AnimTask_CycleMagicalLeafPal = AnimTasks.CycleMagicalLeafPal + +--- pret AnimTask_AcidArmor (pokefirered/src/battle_anim_effects_3.c:3222) +function AnimTasks.AcidArmor(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local whichMon = t.data[0] or 0 + local side = vm:resolveBattlerSide(whichMon) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._step = 0 + t._timer = 0 + t._wave = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + t._wave = (t._wave + 16) % 256 + p.ox = Sin(t._wave, 4) + + if t._step == 0 then + t._timer = t._timer + 1 + p.alpha = math.max(0, 1 - (t._timer / 32)) + if t._timer >= 32 then + t._step = 1 + t._timer = 0 + end + elseif t._step == 1 then + t._timer = t._timer + 1 + if t._timer >= 16 then + t._step = 2 + t._timer = 0 + end + elseif t._step == 2 then + t._timer = t._timer + 1 + p.alpha = math.min(1, t._timer / 32) + if t._timer >= 32 then + p.ox = 0 + p.alpha = 1 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.AcidArmor = AnimTasks.AcidArmor +AnimTasks.REGISTRY.AnimTask_AcidArmor = AnimTasks.AcidArmor + +--- pret AnimTask_TransformMon (pokefirered/src/battle_anim_effects_3.c:2223) +function AnimTasks.TransformMon(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._timer = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + t._timer = t._timer + 1 + if t._timer < 20 then + p.flash = (t._timer % 4 < 2) and 1 or 0 + p.sx = 1 + Sin(t._timer * 12, 32) / 256 + p.sy = 1 - Sin(t._timer * 12, 32) / 256 + else + p.flash = 0 + p.sx = 1 + p.sy = 1 + destroy_task(t) + end +end + +AnimTasks.REGISTRY.TransformMon = AnimTasks.TransformMon +AnimTasks.REGISTRY.AnimTask_TransformMon = AnimTasks.TransformMon + +--- General Battler Affine Animation Tables & Engine (pokefirered/src/battle_anim_mons.c:1677, 1690) +local AFFINE_COMMANDS = { + DefenseCurl = { + { dx = -12, dy = 20, dr = 0, dur = 8 }, + { dx = 12, dy = -20, dr = 0, dur = 8 }, + loop = 2, + }, + Stockpile = { + { dx = 8, dy = -8, dr = 0, dur = 12 }, + { dx = -16, dy = 16, dr = 0, dur = 12 }, + { dx = 8, dy = -8, dr = 0, dur = 12 }, + loop = 1, + }, + SpitUp = { + { dx = 0, dy = 6, dr = 0, dur = 20 }, + { dx = 0, dy = 0, dr = 0, dur = 20 }, + { dx = 0, dy = -18, dr = 0, dur = 6 }, + { dx = -18, dy = -18, dr = 0, dur = 3 }, + { dx = 0, dy = 0, dr = 0, dur = 15 }, + { dx = 4, dy = 4, dr = 0, dur = 13 }, + }, + Swallow = { + { dx = 0, dy = 6, dr = 0, dur = 20 }, + { dx = 0, dy = 0, dr = 0, dur = 20 }, + { dx = 7, dy = -30, dr = 0, dur = 6 }, + { dx = 0, dy = 0, dr = 0, dur = 20 }, + { dx = -2, dy = 3, dr = 0, dur = 20 }, + }, + StretchBattlerUp = { + { dx = 10, dy = -13, dr = 0, dur = 10 }, + { dx = -10, dy = 13, dr = 0, dur = 10 }, + }, + GrowAndShrink = { + { dx = 4, dy = 4, dr = 0, dur = 16 }, + { dx = 0, dy = 0, dr = 0, dur = 32 }, + { dx = -4, dy = -4, dr = 0, dur = 16 }, + }, + ThrashMoveMon = { + { dx = 8, dy = -8, dr = 0, dur = 4 }, + { dx = -16, dy = 16, dr = 0, dur = 8 }, + { dx = 8, dy = -8, dr = 0, dur = 4 }, + loop = 2, + }, + MeditateStretch = { + { dx = 0, dy = 6, dr = 0, dur = 16 }, + { dx = 0, dy = -6, dr = 0, dur = 16 }, + loop = 2, + }, + SlackOffSquish = { + { dx = -4, dy = 8, dr = 0, dur = 8 }, + { dx = 4, dy = -8, dr = 0, dur = 8 }, + }, + SmellingSaltsSquish = { + { dx = 10, dy = -10, dr = 0, dur = 6 }, + { dx = -10, dy = 10, dr = 0, dur = 6 }, + }, + FacadeSquish = { + { dx = 8, dy = -8, dr = 0, dur = 4 }, + { dx = -16, dy = 16, dr = 0, dur = 8 }, + { dx = 8, dy = -8, dr = 0, dur = 4 }, + }, + Uproar = { + { dx = 12, dy = -12, dr = 0, dur = 4 }, + { dx = -24, dy = 24, dr = 0, dur = 8 }, + { dx = 12, dy = -12, dr = 0, dur = 4 }, + loop = 3, + }, +} + +local function stepAffineAnim(task, p, cmds) + if not task._affineInited then + task._affineInited = true + task._affineCmdIdx = 1 + task._affineFrameTimer = 0 + task._affineLoopCount = cmds.loop or 0 + task._affineScaleX = 256 + task._affineScaleY = 256 + task._affineRot = 0 + end + + local cmd = cmds[task._affineCmdIdx] + if not cmd then + p.sx = 1 + p.sy = 1 + p.rotation = 0 + p.oy = 0 + return false + end + + task._affineScaleX = task._affineScaleX + (cmd.dx or 0) + task._affineScaleY = task._affineScaleY + (cmd.dy or 0) + task._affineRot = (task._affineRot + (cmd.dr or 0)) % 65536 + + p.sx = 256 / math.max(1, task._affineScaleX) + p.sy = 256 / math.max(1, task._affineScaleY) + p.rotation = (task._affineRot / 256) * (2 * math.pi) + p.oy = math.floor((64 - (64 * 256 / math.max(1, task._affineScaleY))) / 2) + + task._affineFrameTimer = task._affineFrameTimer + 1 + if task._affineFrameTimer >= (cmd.dur or 1) then + task._affineFrameTimer = 0 + task._affineCmdIdx = task._affineCmdIdx + 1 + if task._affineCmdIdx > #cmds then + if task._affineLoopCount > 0 then + task._affineLoopCount = task._affineLoopCount - 1 + task._affineCmdIdx = 1 + else + p.sx = 1 + p.sy = 1 + p.rotation = 0 + p.oy = 0 + return false + end + end + end + return true +end + +local function runBattlerAffineTask(t, vm, side, cmds) + local Anim = require("src.core.game3.battle.anim") + if not t._p then + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + end + if not stepAffineAnim(t, t._p, cmds) then + destroy_task(t) + end +end + +--- Affine Command Task Handlers +function AnimTasks.DefenseCurlDeformMon(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.DefenseCurl) end +AnimTasks.REGISTRY.DefenseCurlDeformMon = AnimTasks.DefenseCurlDeformMon +AnimTasks.REGISTRY.AnimTask_DefenseCurlDeformMon = AnimTasks.DefenseCurlDeformMon + +function AnimTasks.StockpileDeformMon(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.Stockpile) end +AnimTasks.REGISTRY.StockpileDeformMon = AnimTasks.StockpileDeformMon +AnimTasks.REGISTRY.AnimTask_StockpileDeformMon = AnimTasks.StockpileDeformMon + +function AnimTasks.SpitUpDeformMon(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.SpitUp) end +AnimTasks.REGISTRY.SpitUpDeformMon = AnimTasks.SpitUpDeformMon +AnimTasks.REGISTRY.AnimTask_SpitUpDeformMon = AnimTasks.SpitUpDeformMon + +function AnimTasks.SwallowDeformMon(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.Swallow) end +AnimTasks.REGISTRY.SwallowDeformMon = AnimTasks.SwallowDeformMon +AnimTasks.REGISTRY.AnimTask_SwallowDeformMon = AnimTasks.SwallowDeformMon + +function AnimTasks.StretchTargetUp(t, vm) runBattlerAffineTask(t, vm, vm:targetSide(), AFFINE_COMMANDS.StretchBattlerUp) end +AnimTasks.REGISTRY.StretchTargetUp = AnimTasks.StretchTargetUp +AnimTasks.REGISTRY.AnimTask_StretchTargetUp = AnimTasks.StretchTargetUp + +function AnimTasks.StretchAttackerUp(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.StretchBattlerUp) end +AnimTasks.REGISTRY.StretchAttackerUp = AnimTasks.StretchAttackerUp +AnimTasks.REGISTRY.AnimTask_StretchAttackerUp = AnimTasks.StretchAttackerUp + +function AnimTasks.GrowAndShrink(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.GrowAndShrink) end +AnimTasks.REGISTRY.GrowAndShrink = AnimTasks.GrowAndShrink +AnimTasks.REGISTRY.AnimTask_GrowAndShrink = AnimTasks.GrowAndShrink + +function AnimTasks.ThrashMoveMon(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.ThrashMoveMon) end +AnimTasks.REGISTRY.ThrashMoveMon = AnimTasks.ThrashMoveMon +AnimTasks.REGISTRY.AnimTask_ThrashMoveMon = AnimTasks.ThrashMoveMon + +function AnimTasks.MeditateStretchAttacker(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.MeditateStretch) end +AnimTasks.REGISTRY.MeditateStretchAttacker = AnimTasks.MeditateStretchAttacker +AnimTasks.REGISTRY.AnimTask_MeditateStretchAttacker = AnimTasks.MeditateStretchAttacker + +function AnimTasks.SlackOffSquish(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.SlackOffSquish) end +AnimTasks.REGISTRY.SlackOffSquish = AnimTasks.SlackOffSquish +AnimTasks.REGISTRY.AnimTask_SlackOffSquish = AnimTasks.SlackOffSquish + +function AnimTasks.SmellingSaltsSquish(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.SmellingSaltsSquish) end +AnimTasks.REGISTRY.SmellingSaltsSquish = AnimTasks.SmellingSaltsSquish +AnimTasks.REGISTRY.AnimTask_SmellingSaltsSquish = AnimTasks.SmellingSaltsSquish + +function AnimTasks.Uproar(t, vm) runBattlerAffineTask(t, vm, vm:attackerSide(), AFFINE_COMMANDS.Uproar) end +AnimTasks.REGISTRY.Uproar = AnimTasks.Uproar +AnimTasks.REGISTRY.AnimTask_Uproar = AnimTasks.Uproar + +--- pret AnimTask_RotateMonSpriteToSide / RotateMonToSideAndRestore (pokefirered/src/battle_anim_mon_movement.c:778, 812) +-- arg 0: duration, arg 1: rotDelta, arg 2: whichMon, arg 3: returnMode (0=stay, 1=reset, 2=rotate back) +function AnimTasks.RotateMonSpriteToSide(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local duration = tonumber(t.data[0]) or 1 + local speed = tonumber(t.data[1]) or 0 + local whichMon = t.data[2] or 0 + local returnMode = tonumber(t.data[3]) or 0 + local side = vm:resolveBattlerSide(whichMon) + local isPlayer = (side == "player") + if isPlayer then + speed = -speed + end + t._duration = duration + t._speed = speed + t._whichMon = whichMon + t._returnMode = returnMode + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._rot = 0 + t._timer = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + t._rot = t._rot + t._speed + p.rotation = (t._rot / 256) * (2 * math.pi) + p.oy = math.floor(Sin(math.floor(math.abs(t._rot) / 256) % 256, 16)) + + t._timer = t._timer + 1 + if t._timer >= t._duration then + if t._returnMode == 1 then + p.rotation = 0 + p.oy = 0 + destroy_task(t) + elseif t._returnMode == 2 then + t._timer = 0 + t._speed = -t._speed + t._returnMode = 1 + else + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.RotateMonSpriteToSide = AnimTasks.RotateMonSpriteToSide +AnimTasks.REGISTRY.AnimTask_RotateMonSpriteToSide = AnimTasks.RotateMonSpriteToSide +AnimTasks.REGISTRY.RotateMonToSideAndRestore = AnimTasks.RotateMonSpriteToSide +AnimTasks.REGISTRY.AnimTask_RotateMonToSideAndRestore = AnimTasks.RotateMonSpriteToSide + +--- pret AnimTask_RockMonBackAndForth (pokefirered/src/battle_anim_effects_3.c:2643) +-- arg 0: whichBattler, arg 1: numRocks, arg 2: speedIncrease +function AnimTasks.RockMonBackAndForth(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local whichMon = t.data[0] or 0 + local numRocks = tonumber(t.data[1]) or 2 + local speedInc = math.max(0, math.min(2, tonumber(t.data[2]) or 0)) + if numRocks <= 0 then + destroy_task(t) + return + end + local side = vm:resolveBattlerSide(whichMon) + local p = Anim.present(side) + if not p then + destroy_task(t) + return + end + t._p = p + t._side = side + t._step = 0 + t._timer = 0 + t._rot = 0 + t._halfDur = 8 - (2 * speedInc) + t._rotSpeed = 0x100 + (speedInc * 128) + t._xSpeed = speedInc + 2 + t._repeats = numRocks - 1 + if side == "enemy" or (side ~= "player" and not vm.isReversed) then + t._rotSpeed = -t._rotSpeed + t._xSpeed = -t._xSpeed + end + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if t._step == 0 then + p.ox = (p.ox or 0) + t._xSpeed + t._rot = t._rot - t._rotSpeed + p.rotation = (t._rot / 65536) * (2 * math.pi) + p.oy = math.floor(Sin(math.floor(math.abs(t._rot) / 256) % 256, 16)) + t._timer = t._timer + 1 + if t._timer >= t._halfDur then + t._timer = 0 + t._step = 1 + end + elseif t._step == 1 then + p.ox = (p.ox or 0) - t._xSpeed + t._rot = t._rot + t._rotSpeed + p.rotation = (t._rot / 65536) * (2 * math.pi) + p.oy = math.floor(Sin(math.floor(math.abs(t._rot) / 256) % 256, 16)) + t._timer = t._timer + 1 + if t._timer >= (t._halfDur * 2) then + t._timer = 0 + t._step = 2 + end + elseif t._step == 2 then + p.ox = (p.ox or 0) + t._xSpeed + t._rot = t._rot - t._rotSpeed + p.rotation = (t._rot / 65536) * (2 * math.pi) + p.oy = math.floor(Sin(math.floor(math.abs(t._rot) / 256) % 256, 16)) + t._timer = t._timer + 1 + if t._timer >= t._halfDur then + if t._repeats > 0 then + t._repeats = t._repeats - 1 + t._timer = 0 + t._step = 0 + else + t._step = 3 + end + end + elseif t._step >= 3 then + p.ox = 0 + p.oy = 0 + p.rotation = 0 + destroy_task(t) + end +end + +AnimTasks.REGISTRY.RockMonBackAndForth = AnimTasks.RockMonBackAndForth +AnimTasks.REGISTRY.AnimTask_RockMonBackAndForth = AnimTasks.RockMonBackAndForth +AnimTasks.REGISTRY.CycleMagicalLeafPal = AnimTasks.RockMonBackAndForth +AnimTasks.REGISTRY.AnimTask_CycleMagicalLeafPal = AnimTasks.RockMonBackAndForth + +--- pret AnimTask_ScaleMonAndRestore (pokefirered/src/battle_anim_mon_movement.c:740) +-- arg 0: dx, arg 1: dy, arg 2: duration, arg 3: whichMon +function AnimTasks.ScaleMonAndRestore(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local dx = tonumber(t.data[0]) or 0 + local dy = tonumber(t.data[1]) or 0 + local duration = math.max(1, tonumber(t.data[2]) or 1) + local whichMon = t.data[3] or 0 + local side = vm:resolveBattlerSide(whichMon) + t._dx = dx + t._dy = dy + t._duration = duration + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._scaleX = 256 + t._scaleY = 256 + t._timer = 0 + t._phase = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + t._scaleX = t._scaleX + t._dx + t._scaleY = t._scaleY + t._dy + p.sx = 256 / math.max(1, t._scaleX) + p.sy = 256 / math.max(1, t._scaleY) + p.oy = math.floor((64 - (64 * 256 / math.max(1, t._scaleY))) / 2) + + t._timer = t._timer + 1 + if t._timer >= t._duration then + if t._phase == 0 then + t._phase = 1 + t._timer = 0 + t._dx = -t._dx + t._dy = -t._dy + else + p.sx = 1 + p.sy = 1 + p.oy = 0 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.ScaleMonAndRestore = AnimTasks.ScaleMonAndRestore +AnimTasks.REGISTRY.AnimTask_ScaleMonAndRestore = AnimTasks.ScaleMonAndRestore + +--- pret AnimTask_Minimize (pokefirered/src/battle_anim_effects_2.c:2033) +function AnimTasks.Minimize(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._step = 0 + t._frame = 0 + t._cycle = 0 + t._scale = 256 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if t._step == 0 then + t._scale = t._scale + 0x28 + p.sx = 256 / t._scale + p.sy = 256 / t._scale + p.oy = math.floor((64 - (64 * 256 / t._scale)) / 2) + t._frame = t._frame + 1 + if t._frame >= 32 then + t._frame = 0 + t._cycle = t._cycle + 1 + if t._cycle >= 3 then + t._step = 2 + else + t._scale = 256 + t._step = 0 + end + end + elseif t._step == 2 then + t._frame = t._frame + 1 + if t._frame >= 32 then + t._frame = 0 + t._step = 3 + end + elseif t._step == 3 then + t._scale = t._scale - 0x50 + p.sx = 256 / math.max(1, t._scale) + p.sy = 256 / math.max(1, t._scale) + p.oy = math.floor((64 - (64 * 256 / math.max(1, t._scale))) / 2) + t._frame = t._frame + 1 + if t._frame >= 16 or t._scale <= 256 then + p.sx = 1 + p.sy = 1 + p.oy = 0 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.Minimize = AnimTasks.Minimize +AnimTasks.REGISTRY.AnimTask_Minimize = AnimTasks.Minimize + +--- pret AnimTask_Withdraw (pokefirered/src/battle_anim_effects_2.c:1377) +function AnimTasks.Withdraw(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:attackerSide() + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._rot = 0 + t._step = 0 + t._pause = 0 + end + + local p = t._p + if not p then + destroy_task(t) + return + end + + if t._step == 0 then + t._rot = t._rot + 0xB0 + local r = t._rot + if t._side == "player" then r = -r end + p.rotation = (r / 256) * (2 * math.pi / 256) + p.oy = math.floor(Sin(math.floor(math.abs(r) / 256) % 256, 16)) + if t._rot >= 0xF20 then + t._step = 1 + end + elseif t._step == 1 then + t._pause = t._pause + 1 + if t._pause >= 30 then + t._step = 2 + end + elseif t._step == 2 then + t._rot = t._rot - 0xB0 + local r = t._rot + if t._side == "player" then r = -r end + p.rotation = (r / 256) * (2 * math.pi / 256) + p.oy = math.floor(Sin(math.floor(math.abs(r) / 256) % 256, 16)) + if t._rot <= 0 then + p.rotation = 0 + p.oy = 0 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.Withdraw = AnimTasks.Withdraw +AnimTasks.REGISTRY.AnimTask_Withdraw = AnimTasks.Withdraw + +--- pret AnimTask_SwayMon (pokefirered/src/battle_anim_mon_movement.c:680) +-- arg 0: dir (0=horiz, 1=vert); arg 1: amp; arg 2: period; arg 3: num sways; arg 4: which mon (0=atk, 1=tgt) +function AnimTasks.SwayMon(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local dir = tonumber(t.data[0]) or 0 + local amp = tonumber(t.data[1]) or 8 + local period = tonumber(t.data[2]) or 0x100 + local numSways = math.max(1, tonumber(t.data[3]) or 1) + local which = t.data[4] or 0 + local side = vm:resolveBattlerSide(which) + if vm:attackerSide() ~= "player" then + amp = -amp + end + t._dir = dir + t._amp = amp + t._period = period + t._numSways = numSways + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._sineIndex = 0 + t._flag11 = 0 + t._flag12 = 1 + end + local p = t._p + if not p then + destroy_task(t) + return + end + t._sineIndex = (t._sineIndex + t._period) % 65536 + local waveIdx = math.floor(t._sineIndex / 256) % 256 + local sineVal = Sin(waveIdx, t._amp) + if t._dir == 0 then + p.ox = sineVal + else + if t._side == "player" then + p.oy = math.abs(sineVal) + else + p.oy = -math.abs(sineVal) + end + end + if (waveIdx > 0x7F and t._flag11 == 0 and t._flag12 == 1) + or (waveIdx < 0x7F and t._flag11 == 1 and t._flag12 == 0) then + t._flag11 = 1 - t._flag11 + t._flag12 = 1 - t._flag12 + t._numSways = t._numSways - 1 + if t._numSways <= 0 then + p.ox = 0 + p.oy = 0 + destroy_task(t) + end + end +end + +AnimTasks.REGISTRY.SwayMon = AnimTasks.SwayMon +AnimTasks.REGISTRY.AnimTask_SwayMon = AnimTasks.SwayMon + +--- pret AnimTask_WindUpLunge (pokefirered/src/battle_anim_mon_movement.c:579) +-- arg 0: anim battler; arg 1: hspeed; arg 2: wave amp; arg 3: dur1; arg 4: delay; arg 5: target x; arg 6: lunge dur +function AnimTasks.WindUpLunge(t, vm) + local Anim = require("src.core.game3.battle.anim") + local AnimVm = require("src.core.game3.battle.anim_vm") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + local speed1 = tonumber(t.data[1]) or 0 + local targetX2 = tonumber(t.data[5]) or 0 + if side ~= "player" then + speed1 = -speed1 + targetX2 = -targetX2 + end + t._amp = tonumber(t.data[2]) or 0 + t._dur1 = math.max(1, tonumber(t.data[3]) or 10) + t._delay = tonumber(t.data[4]) or 0 + t._dur2 = math.max(1, tonumber(t.data[6]) or 4) + t._speed1_fp = math.floor((speed1 * 256) / t._dur1) + t._speed2_fp = math.floor((targetX2 * 256) / t._dur2) + t._wavePeriod = math.floor(0x8000 / t._dur1) + t._waveAngle = 0 + t._subX1 = 0 + t._subX2 = 0 + t._step = 1 + t._origZ = t._p.z + t._p.z = AnimVm.Z.FRONT + end + local p = t._p + if not p then + destroy_task(t) + return + end + if t._step == 1 then + t._subX1 = t._subX1 + t._speed1_fp + p.ox = math.floor(t._subX1 / 256) + p.oy = Sin(math.floor(t._waveAngle / 256), t._amp) + t._waveAngle = t._waveAngle + t._wavePeriod + t._dur1 = t._dur1 - 1 + if t._dur1 <= 0 then + t._step = 2 + end + elseif t._step == 2 then + if t._delay > 0 then + t._delay = t._delay - 1 + else + t._subX2 = t._subX2 + t._speed2_fp + p.ox = math.floor(t._subX2 / 256) + math.floor(t._subX1 / 256) + t._dur2 = t._dur2 - 1 + if t._dur2 <= 0 then + if t._origZ then p.z = t._origZ end + destroy_task(t) + end + end + end +end + +AnimTasks.REGISTRY.WindUpLunge = AnimTasks.WindUpLunge +AnimTasks.REGISTRY.AnimTask_WindUpLunge = AnimTasks.WindUpLunge + +--- pret AnimTask_DragonDanceWaver: wave distortion & sway. +function AnimTasks.DragonDanceWaver(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(16, tonumber(t.data[0]) or 28) + local side = vm:attackerSide() + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(side) + + if p then + p.ox = math.floor(math.sin(frame * 0.45) * 6) + p.rotation = math.sin(frame * 0.3) * 0.12 + end + + if frame >= dur then + if p then p.ox = 0; p.rotation = 0 end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.DragonDanceWaver = AnimTasks.DragonDanceWaver +AnimTasks.REGISTRY.AnimTask_DragonDanceWaver = AnimTasks.DragonDanceWaver + +--- Utility Side Query Tasks (pokefirered/src/battle_anim_utility_funcs.c:700) +function AnimTasks.GetAttackerSide(t, vm) + local side = vm:attackerSide() + vm.args[7] = (side == "player") and 0 or 1 destroy_task(t) end +AnimTasks.REGISTRY.GetAttackerSide = AnimTasks.GetAttackerSide +AnimTasks.REGISTRY.AnimTask_GetAttackerSide = AnimTasks.GetAttackerSide + +function AnimTasks.GetTargetSide(t, vm) + local side = vm:targetSide() + vm.args[7] = (side == "player") and 0 or 1 + destroy_task(t) +end + +AnimTasks.REGISTRY.GetTargetSide = AnimTasks.GetTargetSide +AnimTasks.REGISTRY.AnimTask_GetTargetSide = AnimTasks.GetTargetSide + +function AnimTasks.GetTargetIsAttackerPartner(t, vm) + vm.args[7] = 0 -- In singles, target is never partner + destroy_task(t) +end + +AnimTasks.REGISTRY.GetTargetIsAttackerPartner = AnimTasks.GetTargetIsAttackerPartner +AnimTasks.REGISTRY.AnimTask_GetTargetIsAttackerPartner = AnimTasks.GetTargetIsAttackerPartner + +function AnimTasks.SetAllNonAttackersInvisiblity(t, vm) + local Anim = require("src.core.game3.battle.anim") + local invis = (tonumber(t.data[0]) or 0) ~= 0 + local atkSide = vm:attackerSide() + local otherSide = (atkSide == "player") and "enemy" or "player" + local p = Anim.present(otherSide) + if p then p.visible = not invis end + destroy_task(t) +end + +AnimTasks.REGISTRY.SetAllNonAttackersInvisiblity = AnimTasks.SetAllNonAttackersInvisiblity +AnimTasks.REGISTRY.AnimTask_SetAllNonAttackersInvisiblity = AnimTasks.SetAllNonAttackersInvisiblity + +--- pret AnimTask_TranslateMonElliptical / AnimTask_TranslateMonEllipticalRespectSide (pokefirered/src/battle_anim_mon_movement.c:334, 377) +-- arg 0: battler; arg 1: ellipse width; arg 2: ellipse height; arg 3: num loops; arg 4: speed (0-5) +function AnimTasks.TranslateMonElliptical(t, vm) + local Anim = require("src.core.game3.battle.anim") + local AnimVm = require("src.core.game3.battle.anim_vm") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0]) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + t._radiusX = tonumber(t.data[1]) or 0 + t._radiusY = tonumber(t.data[2]) or 0 + t._numLoops = math.max(1, tonumber(t.data[3]) or 1) + local spd = math.min(5, math.max(0, tonumber(t.data[4]) or 0)) + t._wavePeriod = 2 ^ spd + t._angle = 0 + t._origZ = t._p.z + t._p.z = AnimVm.Z.FRONT + end + local p = t._p + if not p then + destroy_task(t) + return + end + p.ox = Sin(t._angle, t._radiusX) + p.oy = -Cos(t._angle, t._radiusY) + t._radiusY + t._angle = (t._angle + t._wavePeriod) % 256 + if t._angle == 0 then + t._numLoops = t._numLoops - 1 + end + if t._numLoops <= 0 then + p.ox = 0 + p.oy = 0 + if t._origZ then p.z = t._origZ end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.TranslateMonElliptical = AnimTasks.TranslateMonElliptical +AnimTasks.REGISTRY.AnimTask_TranslateMonElliptical = AnimTasks.TranslateMonElliptical + +function AnimTasks.TranslateMonEllipticalRespectSide(t, vm) + local atkSide = vm:attackerSide() + if atkSide ~= "player" then + t.data[1] = -(tonumber(t.data[1]) or 0) + end + AnimTasks.TranslateMonElliptical(t, vm) +end + +AnimTasks.REGISTRY.TranslateMonEllipticalRespectSide = AnimTasks.TranslateMonEllipticalRespectSide +AnimTasks.REGISTRY.AnimTask_TranslateMonEllipticalRespectSide = AnimTasks.TranslateMonEllipticalRespectSide + +--- pret AnimTask_SlideOffScreen (pokefirered/src/battle_anim_mon_movement.c:626) +-- arg 0: battler; arg 1: speed +function AnimTasks.SlideOffScreen(t, vm) + local Anim = require("src.core.game3.battle.anim") + if not t._inited then + t._inited = true + local side = vm:resolveBattlerSide(t.data[0] or 0) + t._side = side + t._p = Anim.present(side) + if not t._p then + destroy_task(t) + return + end + local speed = tonumber(t.data[1]) or 8 + local tgtSide = vm:targetSide() + if tgtSide == "player" then + speed = -speed + end + t._speed = speed + end + local p = t._p + if not p then + destroy_task(t) + return + end + p.ox = (p.ox or 0) + t._speed + local cx, _ = vm:battlerCenter(t._side) + if cx < -40 or cx > 280 then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SlideOffScreen = AnimTasks.SlideOffScreen +AnimTasks.REGISTRY.AnimTask_SlideOffScreen = AnimTasks.SlideOffScreen + +--- pret AnimTask_VoltTackleBolt (pokefirered/src/battle_anim_electric.c:985) +-- arg 0: bolt step index (0=start, 1..3=mid, 4=impact) +function AnimTasks.VoltTackleBolt(t, vm) + if not t._inited then + t._inited = true + local stepIdx = tonumber(t.data[0]) or 0 + local isPlayer = (vm:attackerSide() == "player") + local dir = isPlayer and 1 or -1 + local ax, ay = vm:battlerCenter("attacker") + local tx, ty = vm:battlerCenter("target") + local startX, startY, endX + if stepIdx == 0 then + startX, startY = ax, ay + endX = (dir * 128) + 120 + elseif stepIdx == 4 then + startX = 120 - (dir * 128) + startY = ty + endX = tx - (dir * 32) + else + startX = ((stepIdx % 2) == 1) and 256 or -16 + endX = ((stepIdx % 2) == 1) and -16 or 256 + startY = isPlayer and (80 - stepIdx * 10) or (stepIdx * 10 + 40) + end + t._currX = startX + t._startY = startY + t._endX = endX + t._dir = (startX < endX) and 1 or -1 + t._timer = 0 + t._activeBolts = 0 + end + + local pack = vm._pack + local imgMeta = pack and pack.tags and (pack.tags["ELECTRICITY"] or pack.tags["SPARK"] or pack.tags["LIGHTNING"]) + if imgMeta and imgMeta.image then + local spr = AnimSprites.acquire({ + x = t._currX, + y = t._startY + math.random(-8, 8), + z = AnimSprites.Z.GLOBAL_FRONT, + image = imgMeta.image, + w = imgMeta.frameW or 16, + h = imgMeta.frameH or 16, + hFlip = (t._dir < 0), + tag = "ELECTRICITY", + callback = function(s) + s.data[0] = (s.data[0] or 0) + 1 + if s.data[0] >= 6 then AnimSprites.release(s) end + end, + }) + if spr then + spr._baseW = imgMeta.frameW or 16 + spr._baseH = imgMeta.frameH or 16 + end + end + + t._currX = t._currX + t._dir * 16 + local reached = (t._dir == 1 and t._currX >= t._endX) or (t._dir == -1 and t._currX <= t._endX) + if reached then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.VoltTackleBolt = AnimTasks.VoltTackleBolt +AnimTasks.REGISTRY.AnimTask_VoltTackleBolt = AnimTasks.VoltTackleBolt + +--- pret AnimTask_ShockWaveProgressingBolt / AnimTask_ShockWaveLightning (pokefirered/src/battle_anim_electric.c:1107, 1226) +function AnimTasks.ShockWaveProgressingBolt(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + local ax, ay = vm:battlerCenter("attacker") + local tx, ty = vm:battlerCenter("target") + local pack = vm._pack + + if frame < 16 and frame % 2 == 0 then + local u = frame / 16 + local bx = ax + (tx - ax) * u + local by = ay + (ty - ay) * u + math.random(-12, 12) + local imgMeta = pack and pack.tags and (pack.tags["LIGHTNING"] or pack.tags["ELECTRICITY"] or pack.tags["SPARK"]) + if imgMeta and imgMeta.image then + local spr = AnimSprites.acquire({ + x = bx, + y = by, + z = AnimSprites.Z.GLOBAL_FRONT, + image = imgMeta.image, + w = imgMeta.frameW or 32, + h = imgMeta.frameH or 32, + tag = "LIGHTNING", + callback = function(s) + s.data[0] = (s.data[0] or 0) + 1 + if s.data[0] >= 8 then AnimSprites.release(s) end + end, + }) + if spr then + spr._baseW = imgMeta.frameW or 32 + spr._baseH = imgMeta.frameH or 32 + end + end + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.ShockWaveProgressingBolt = AnimTasks.ShockWaveProgressingBolt +AnimTasks.REGISTRY.AnimTask_ShockWaveProgressingBolt = AnimTasks.ShockWaveProgressingBolt +AnimTasks.REGISTRY.ShockWaveLightning = AnimTasks.ShockWaveProgressingBolt +AnimTasks.REGISTRY.AnimTask_ShockWaveLightning = AnimTasks.ShockWaveProgressingBolt +AnimTasks.REGISTRY.ElectricBolt = AnimTasks.ShockWaveProgressingBolt +AnimTasks.REGISTRY.AnimTask_ElectricBolt = AnimTasks.ShockWaveProgressingBolt + +--- pret AnimTask_EruptionLaunchRocks (pokefirered/src/battle_anim_fire.c:754) +function AnimTasks.EruptionLaunchRocks(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + local ax, ay = vm:battlerCenter("attacker") + local pack = vm._pack + + if frame < 20 and frame % 4 == 0 then + local imgMeta = pack and pack.tags and (pack.tags["ROCKS"] or pack.tags["SMALL_ROCK"] or pack.tags["IMPACT"]) + if imgMeta and imgMeta.image then + for _ = 1, 2 do + local spr = AnimSprites.acquire({ + x = ax + math.random(-16, 16), + y = ay, + z = AnimSprites.Z.GLOBAL_FRONT, + image = imgMeta.image, + w = imgMeta.frameW or 16, + h = imgMeta.frameH or 16, + tag = "ROCKS", + callback = function(s) + s.data[0] = (s.data[0] or 0) + 1 + if not s._vx then + s._vx = (math.random() - 0.5) * 3.5 + s._vy = -4.5 - math.random() * 2.0 + end + s._vy = s._vy + 0.3 -- gravity + s.ox = (s.ox or 0) + s._vx + s.oy = (s.oy or 0) + s._vy + s.rotation = (s.rotation or 0) + 0.2 + if s.data[0] >= 24 then AnimSprites.release(s) end + end, + }) + if spr then + spr._baseW = imgMeta.frameW or 16 + spr._baseH = imgMeta.frameH or 16 + end + end + end + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.EruptionLaunchRocks = AnimTasks.EruptionLaunchRocks +AnimTasks.REGISTRY.AnimTask_EruptionLaunchRocks = AnimTasks.EruptionLaunchRocks + +--- pret AnimTask_FrozenIceCube (pokefirered/src/battle_anim_status_effects.c:350) +function AnimTasks.FrozenIceCube(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + local Anim = require("src.core.game3.battle.anim") + local tgtSide = vm:resolveBattlerSide("target") + local pTgt = Anim.present(tgtSide) + t.z = AnimSprites.Z.GLOBAL_FRONT + + if frame < dur and pTgt then + pTgt.blendColor = { 0.4, 0.8, 1.0 } + pTgt.blendCoeff = 0.5 + 0.2 * math.sin((frame / 8) * math.pi) + elseif frame >= dur and pTgt then + pTgt.blendCoeff = 0 + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local tx, ty = vm:battlerCenter(tgtSide) + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Crystalline faceted ice block overlay + love.graphics.setColor(0.60, 0.88, 1.00, a * 0.55) + love.graphics.rectangle("fill", tx - 28, ty - 28, 56, 56, 4, 4) + love.graphics.setColor(0.90, 0.98, 1.00, a * 0.85) + love.graphics.setLineWidth(1.5) + love.graphics.rectangle("line", tx - 28, ty - 28, 56, 56, 4, 4) + -- Shimmering internal prism facet lines + love.graphics.line(tx - 28, ty - 12, tx + 28, ty - 20) + love.graphics.line(tx - 12, ty + 28, tx + 18, ty - 28) + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + if pTgt then pTgt.blendCoeff = 0 end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.FrozenIceCube = AnimTasks.FrozenIceCube +AnimTasks.REGISTRY.AnimTask_FrozenIceCube = AnimTasks.FrozenIceCube + +--- pret AnimTask_Hail (pokefirered/src/battle_anim_ice.c:1253) +function AnimTasks.Hail(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_FRONT + + if frame == 1 then + t._particles = {} + end + + -- Spawn falling hail shards + if frame < 26 and frame % 2 == 0 then + t._particles = t._particles or {} + for _ = 1, 4 do + t._particles[#t._particles + 1] = { + x = math.random(10, 240), + y = -10, + vx = -3.5, + vy = 6.0 + math.random() * 2.0, + life = 0, + maxLife = 20, + } + end + end + + if t._particles then + local alive = {} + for _, p in ipairs(t._particles) do + p.life = p.life + 1 + p.x = p.x + p.vx + p.y = p.y + p.vy + if p.life < p.maxLife and p.y < 120 then + alive[#alive + 1] = p + end + end + t._particles = alive + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + if not task._particles then return end + love.graphics.setLineWidth(1.5) + for _, p in ipairs(task._particles) do + local a = 1.0 - (p.life / p.maxLife) + love.graphics.setColor(0.85, 0.95, 1.0, a * 0.9) + love.graphics.line(p.x, p.y, p.x + p.vx * 1.5, p.y + p.vy * 1.5) + love.graphics.setColor(1, 1, 1, a) + love.graphics.circle("fill", p.x, p.y, 1.5) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.Hail = AnimTasks.Hail +AnimTasks.REGISTRY.AnimTask_Hail = AnimTasks.Hail + +--- pret Atmospheric Fog & Spore Tasks (Mist, Haze, Spore, Smokescreen) +function AnimTasks.AtmosphericFog(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(16, tonumber(t.data[0]) or 28) + t.z = AnimSprites.Z.MID_FIELD + + if frame == 1 then + t._particles = {} + for _ = 1, 8 do + t._particles[#t._particles + 1] = { + x = math.random(10, 230), + y = math.random(35, 100), + vx = (math.random() > 0.5 and 0.4 or -0.4), + radius = math.random(18, 32), + life = 0, + maxLife = dur, + } + end + end + + if t._particles then + for _, p in ipairs(t._particles) do + p.life = p.life + 1 + p.x = p.x + p.vx + end + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + if not task._particles then return end + local f = task.data[14] or 0 + local globalAlpha = (f <= 8) and (f / 8) or ((f >= dur - 8) and ((dur - f) / 8) or 1.0) + for _, p in ipairs(task._particles) do + love.graphics.setColor(0.92, 0.94, 0.98, globalAlpha * 0.35) + love.graphics.circle("fill", p.x, p.y, p.radius) + end + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.MistBallFog = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.AnimTask_MistBallFog = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.HazeScrollingFog = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.AnimTask_HazeScrollingFog = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.SporeDoubleBattle = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.AnimTask_SporeDoubleBattle = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.SmokescreenImpact = AnimTasks.AtmosphericFog +AnimTasks.REGISTRY.AnimTask_SmokescreenImpact = AnimTasks.AtmosphericFog + +--- pret AnimTask_LoadSandstormBackground (pokefirered/src/battle_anim_rock.c:405) +function AnimTasks.LoadSandstormBackground(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.MID_FIELD + + if frame == 1 then + t._particles = {} + for _ = 1, 14 do + t._particles[#t._particles + 1] = { + x = math.random(0, 240), + y = math.random(20, 110), + vx = -(4.0 + math.random() * 3.0), + vy = (math.random() - 0.5) * 1.5, + len = math.random(8, 20), + life = 0, + maxLife = dur, + } + end + end + + if t._particles then + for _, p in ipairs(t._particles) do + p.life = p.life + 1 + p.x = p.x + p.vx + p.y = p.y + p.vy + if p.x < -20 then p.x = 260; p.y = math.random(20, 110) end + end + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + if not task._particles then return end + local f = task.data[14] or 0 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Ambient sandstorm tint + love.graphics.setColor(0.78, 0.65, 0.40, a * 0.30) + love.graphics.rectangle("fill", 0, 0, 240, 112) + -- Swirling wind and dust streaks + love.graphics.setColor(0.90, 0.80, 0.55, a * 0.70) + love.graphics.setLineWidth(1.5) + for _, p in ipairs(task._particles) do + love.graphics.line(p.x, p.y, p.x + p.len, p.y - 2) + love.graphics.circle("fill", p.x, p.y, 1.2) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.LoadSandstormBackground = AnimTasks.LoadSandstormBackground +AnimTasks.REGISTRY.AnimTask_LoadSandstormBackground = AnimTasks.LoadSandstormBackground + +--- Sound Tasks with spatial panning +function AnimTasks.SoundTask_PlaySE1WithPanning(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local songId = t.data[0] + local initPan = tonumber(t.data[1]) or -64 + local targetPan = tonumber(t.data[2]) or 63 + local dur = math.max(1, tonumber(t.data[3]) or 16) + + if frame == 1 then + t.data[10] = initPan + local okA, Audio = pcall(require, "src.core.game3.audio") + if okA and Audio and Audio.playSe then + Audio.playSe(songId, { pan = initPan }) + end + end + + local u = math.min(1, frame / dur) + local currentPan = math.floor(initPan + (targetPan - initPan) * u) + t.data[10] = currentPan + + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SoundTask_PlaySE1WithPanning = AnimTasks.SoundTask_PlaySE1WithPanning + +function AnimTasks.SoundTask_AdjustPanningVar(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(1, tonumber(t.data[1]) or 16) + if frame >= dur then + destroy_task(t) + end +end + +AnimTasks.REGISTRY.SoundTask_AdjustPanningVar = AnimTasks.SoundTask_AdjustPanningVar + +function AnimTasks.SetGrayscaleOrOriginalPal(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(12, tonumber(t.data[0]) or 24) + local Anim = require("src.core.game3.battle.anim") + + local u = math.sin((frame / dur) * math.pi) + Anim.setScreenEffect({ type = "grayscale", coeff = u }) + + if frame >= dur then + Anim.setScreenEffect(nil) + destroy_task(t) + end +end + AnimTasks.REGISTRY.SetGrayscaleOrOriginalPal = AnimTasks.SetGrayscaleOrOriginalPal AnimTasks.REGISTRY.AnimTask_SetGrayscaleOrOriginalPal = AnimTasks.SetGrayscaleOrOriginalPal +--- pret AnimTask_PainSplitMovement: visual energy transfer pulse +function AnimTasks.PainSplitMovement(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local pAtk = Anim.present(vm:attackerSide()) + local pTgt = Anim.present(vm:resolveBattlerSide("target")) + if frame < 12 then + if pTgt then pTgt.flash = (frame % 2 == 0) and 1 or 0 end + elseif frame < 24 then + if pTgt then pTgt.flash = 0 end + if pAtk then pAtk.flash = (frame % 2 == 0) and 1 or 0 end + end + if frame >= dur then + if pAtk then pAtk.flash = 0 end + if pTgt then pTgt.flash = 0 end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.PainSplitMovement = AnimTasks.PainSplitMovement +AnimTasks.REGISTRY.AnimTask_PainSplitMovement = AnimTasks.PainSplitMovement + +--- pret AnimTask_DoubleTeam / AnimTask_MonToSubstitute +function AnimTasks.DoubleTeam(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(20, tonumber(t.data[0]) or 30) + local side = vm:attackerSide() + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(side) + if p then + local phase = frame % 6 + p.ox = (phase < 3) and 12 or -12 + p.alpha = 0.65 + 0.35 * math.sin(frame * 0.5) + end + if frame >= dur then + if p then p.ox = 0; p.alpha = 1.0 end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.DoubleTeam = AnimTasks.DoubleTeam +AnimTasks.REGISTRY.AnimTask_DoubleTeam = AnimTasks.DoubleTeam + +function AnimTasks.MonToSubstitute(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 16 + local side = vm:attackerSide() + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(side) + if p then + local u = math.sin((frame / dur) * math.pi) + p.sy = 1.0 - u * 0.3 + p.sx = 1.0 + u * 0.2 + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0 end + destroy_task(t) + end +end + +AnimTasks.REGISTRY.MonToSubstitute = AnimTasks.MonToSubstitute +AnimTasks.REGISTRY.AnimTask_MonToSubstitute = AnimTasks.MonToSubstitute + + +-- ========================================================================= +-- Phase 4: Dynamic Backgrounds, Clones, Distortions & Specialized Subsystems +-- ========================================================================= + +--- Subsystem A: Dynamic Scrolling Backgrounds & High-Altitude Environments + +--- pret AnimTask_MoveSkyUppercutBg (pokefirered/src/battle_anim_fight.c:280) +function AnimTasks.MoveSkyUppercutBg(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + t.z = AnimSprites.Z.GLOBAL_BEHIND + t.data[10] = (t.data[10] or 0) + 14 -- vertical scroll speed upward + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local scrollY = (task.data[10] or 0) % 112 + -- Sky gradient background + love.graphics.setColor(0.20, 0.50, 0.90, a * 0.85) + love.graphics.rectangle("fill", 0, 0, 240, 112) + -- Fast ascending high-altitude cloud speed lines + love.graphics.setColor(0.90, 0.95, 1.00, a * 0.70) + love.graphics.setLineWidth(2) + for i = 0, 8 do + local x = (i * 28 + 14) % 240 + local y = (i * 32 - scrollY * 2) % 128 - 16 + love.graphics.line(x, y, x, y + 24) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.MoveSkyUppercutBg = AnimTasks.MoveSkyUppercutBg +AnimTasks.REGISTRY.AnimTask_MoveSkyUppercutBg = AnimTasks.MoveSkyUppercutBg + +--- pret AnimTask_MoveSeismicTossBg & SeismicTossBgAccelerateDownAtEnd (pokefirered/src/battle_anim_fight.c:320) +function AnimTasks.MoveSeismicTossBg(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 40 + t.z = AnimSprites.Z.GLOBAL_BEHIND + local speed = 2 + (frame * 0.4) + t.data[10] = (t.data[10] or 0) + speed -- accelerating descent + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local scrollY = (task.data[10] or 0) % 112 + -- Space / upper atmosphere deep navy background + love.graphics.setColor(0.04, 0.08, 0.22, a * 0.90) + love.graphics.rectangle("fill", 0, 0, 240, 112) + -- Earth curve horizon glow + love.graphics.setColor(0.18, 0.55, 0.85, a * 0.65) + love.graphics.arc("fill", 120, 180 - scrollY * 0.5, 140, math.pi, math.pi * 2) + -- Re-entry meteor friction streaks + love.graphics.setColor(1.00, 0.65, 0.20, a * 0.80) + love.graphics.setLineWidth(2) + for i = 0, 6 do + local x = (i * 36 + 18) % 240 + local y = (i * 24 + scrollY * 2) % 128 - 16 + love.graphics.line(x, y, x, y + 18) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.MoveSeismicTossBg = AnimTasks.MoveSeismicTossBg +AnimTasks.REGISTRY.AnimTask_MoveSeismicTossBg = AnimTasks.MoveSeismicTossBg +AnimTasks.REGISTRY.SeismicTossBgAccelerateDownAtEnd = AnimTasks.MoveSeismicTossBg +AnimTasks.REGISTRY.AnimTask_SeismicTossBgAccelerateDownAtEnd = AnimTasks.MoveSeismicTossBg + +--- pret AnimTask_PositionFissureBgOnBattler (pokefirered/src/battle_anim_ground.c:450) +function AnimTasks.PositionFissureBgOnBattler(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 40 + t.z = AnimSprites.Z.GLOBAL_BEHIND + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local width = math.min(36, f * 1.5) + -- Ground abyss fissure opening beneath target + love.graphics.setColor(0.08, 0.05, 0.02, a * 0.95) + love.graphics.polygon("fill", tx - width, ty + 20, tx + width, ty + 20, tx + width * 0.7, 112, tx - width * 0.7, 112) + -- Glowing molten/rock core crack lines + love.graphics.setColor(0.85, 0.35, 0.10, a * 0.80) + love.graphics.setLineWidth(2) + love.graphics.line(tx - width, ty + 20, tx, ty + 35, tx + width, ty + 20) + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.PositionFissureBgOnBattler = AnimTasks.PositionFissureBgOnBattler +AnimTasks.REGISTRY.AnimTask_PositionFissureBgOnBattler = AnimTasks.PositionFissureBgOnBattler + +--- pret SetPsychicBackground (Psychic, Psybeam, Teleport, Skill Swap, Extrasensory) +function AnimTasks.SetPsychicBackground(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_BEHIND + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Cosmic psychic distortion background + love.graphics.setColor(0.35, 0.05, 0.45, a * 0.50) + love.graphics.rectangle("fill", 0, 0, 240, 112) + -- Warping psychic concentric rings + love.graphics.setColor(0.85, 0.35, 0.95, a * 0.40) + love.graphics.setLineWidth(1.5) + local cx, cy = vm:battlerCenter("target") + for r = 16, 80, 16 do + local rad = (r + f * 2) % 80 + love.graphics.circle("line", cx, cy, rad) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.SetPsychicBackground = AnimTasks.SetPsychicBackground +AnimTasks.REGISTRY.UnsetPsychicBackground = stub_task + +--- pret AnimTask_HeartsBackground (Attract) +function AnimTasks.HeartsBackground(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + t.z = AnimSprites.Z.GLOBAL_BEHIND + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Pink ambient glow + love.graphics.setColor(0.95, 0.40, 0.65, a * 0.30) + love.graphics.rectangle("fill", 0, 0, 240, 112) + -- Floating background hearts + love.graphics.setColor(1.00, 0.45, 0.70, a * 0.75) + for i = 0, 6 do + local hx = (i * 40 + f * 2) % 250 - 10 + local hy = (i * 20 + math.floor(Sin((hx + f * 4) % 256, 8))) % 100 + 10 + love.graphics.circle("fill", hx - 3, hy, 4) + love.graphics.circle("fill", hx + 3, hy, 4) + love.graphics.polygon("fill", hx - 6, hy + 2, hx + 6, hy + 2, hx, hy + 8) + end + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.HeartsBackground = AnimTasks.HeartsBackground +AnimTasks.REGISTRY.AnimTask_HeartsBackground = AnimTasks.HeartsBackground + +--- pret AnimTask_ScaryFace (pokefirered/src/battle_anim_effects_2.c:3360) +function AnimTasks.ScaryFace(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.MID_FIELD + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local scale = 0.6 + (f / dur) * 0.8 + -- Looming dark shadowy phantom mask zooming behind target + love.graphics.setColor(0.10, 0.02, 0.15, a * 0.70) + love.graphics.circle("fill", tx, ty, 32 * scale) + -- Glowing demonic red eyes + love.graphics.setColor(0.95, 0.15, 0.10, a * 0.95) + love.graphics.circle("fill", tx - 12 * scale, ty - 6 * scale, 4 * scale) + love.graphics.circle("fill", tx + 12 * scale, ty - 6 * scale, 4 * scale) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.ScaryFace = AnimTasks.ScaryFace +AnimTasks.REGISTRY.AnimTask_ScaryFace = AnimTasks.ScaryFace + +--- pret AnimTask_CreateRaindrops (Rain Dance) +function AnimTasks.CreateRaindrops(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + t.z = AnimSprites.Z.GLOBAL_FRONT + + if not t._particles then + t._particles = {} + for _ = 1, 18 do + t._particles[#t._particles + 1] = { + x = math.random(-20, 240), + y = math.random(-20, 100), + vx = -4.0, + vy = 7.0, + len = math.random(10, 18), + } + end + end + + if t._particles then + for _, p in ipairs(t._particles) do + p.x = p.x + p.vx + p.y = p.y + p.vy + if p.y > 115 or p.x < -20 then + p.x = math.random(60, 260) + p.y = -15 + end + end + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + if not task._particles then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(0.60, 0.80, 1.00, a * 0.70) + love.graphics.setLineWidth(1.5) + for _, p in ipairs(task._particles) do + love.graphics.line(p.x, p.y, p.x + p.vx * 1.5, p.y + p.vy * 1.5) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.CreateRaindrops = AnimTasks.CreateRaindrops +AnimTasks.REGISTRY.AnimTask_CreateRaindrops = AnimTasks.CreateRaindrops + + +--- Subsystem B: Shadow Clones, Afterimages & Silhouette Transfers + +--- pret AnimTask_NightShadeClone & NightmareClone (pokefirered/src/battle_anim_ghost.c:280) +function AnimTasks.NightShadeClone(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.MID_FIELD + local Anim = require("src.core.game3.battle.anim") + local atkSide = vm:attackerSide() + local ax, ay = vm:battlerCenter(atkSide) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local scale = 1.0 + (f / dur) * 0.5 + -- Dark rising shadow silhouette clone + love.graphics.setColor(0.08, 0.04, 0.12, a * 0.80) + love.graphics.circle("fill", ax + 6, ay - 8, 28 * scale) + love.graphics.setColor(0.85, 0.10, 0.10, a * 0.90) + love.graphics.circle("fill", ax - 4, ay - 14, 3) + love.graphics.circle("fill", ax + 14, ay - 14, 3) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.NightShadeClone = AnimTasks.NightShadeClone +AnimTasks.REGISTRY.AnimTask_NightShadeClone = AnimTasks.NightShadeClone +AnimTasks.REGISTRY.NightmareClone = AnimTasks.NightShadeClone +AnimTasks.REGISTRY.AnimTask_NightmareClone = AnimTasks.NightShadeClone + +--- pret AnimTask_DestinyBondWhiteShadow +function AnimTasks.DestinyBondWhiteShadow(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 30 + t.z = AnimSprites.Z.GLOBAL_BEHIND + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local rad = math.min(32, f * 1.5) + love.graphics.setColor(0.95, 0.98, 1.00, a * 0.65) + love.graphics.ellipse("fill", ax, ay + 24, rad, rad * 0.4) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.DestinyBondWhiteShadow = AnimTasks.DestinyBondWhiteShadow +AnimTasks.REGISTRY.AnimTask_DestinyBondWhiteShadow = AnimTasks.DestinyBondWhiteShadow + +--- pret Memento Tasks (InitMementoShadow, MoveAttackerMementoShadow, MementoHandleBg, MoveTargetMementoShadow) +function AnimTasks.MementoShadow(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.MID_FIELD + local ax, ay = vm:battlerCenter(vm:attackerSide()) + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local u = math.min(1.0, f / 24) + local curX = ax + (tx - ax) * u + local curY = ay + (ty - ay) * u + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(0.06, 0.02, 0.08, a * 0.85) + love.graphics.ellipse("fill", curX, curY + 20, 24, 10) + love.graphics.circle("fill", curX, curY, 16) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.InitMementoShadow = AnimTasks.MementoShadow +AnimTasks.REGISTRY.MoveAttackerMementoShadow = AnimTasks.MementoShadow +AnimTasks.REGISTRY.MementoHandleBg = AnimTasks.MementoShadow +AnimTasks.REGISTRY.MoveTargetMementoShadow = AnimTasks.MementoShadow +AnimTasks.REGISTRY.SpiteTargetShadow = AnimTasks.MementoShadow + +--- pret RolePlaySilhouette +function AnimTasks.RolePlaySilhouette(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 28 + t.z = AnimSprites.Z.MID_FIELD + local ax, ay = vm:battlerCenter(vm:attackerSide()) + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local u = math.min(1.0, f / 20) + local curX = tx + (ax - tx) * u + local curY = ty + (ay - ty) * u + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(0.40, 0.85, 0.95, a * 0.50) + love.graphics.circle("fill", curX, curY, 24) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.RolePlaySilhouette = AnimTasks.RolePlaySilhouette +AnimTasks.REGISTRY.AnimTask_RolePlaySilhouette = AnimTasks.RolePlaySilhouette +AnimTasks.REGISTRY.TransparentCloneGrowAndShrink = AnimTasks.RolePlaySilhouette + + +--- Subsystem C: Screen Distortions, Spotlights & Lighting + +--- pret ExtrasensoryDistortion & UproarDistortion +function AnimTasks.ScreenDistortionWobble(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local u = math.sin((frame / dur) * math.pi) + Anim.setScreenEffect({ type = "custom_blend", coeff = u * 0.35, targetColor = { 0.7, 0.3, 0.9 } }) + + if frame >= dur then + Anim.setScreenEffect(nil) + destroy_task(t) + end +end +AnimTasks.REGISTRY.ExtrasensoryDistortion = AnimTasks.ScreenDistortionWobble +AnimTasks.REGISTRY.AnimTask_ExtrasensoryDistortion = AnimTasks.ScreenDistortionWobble +AnimTasks.REGISTRY.UproarDistortion = AnimTasks.ScreenDistortionWobble +AnimTasks.REGISTRY.AnimTask_UproarDistortion = AnimTasks.ScreenDistortionWobble + +--- pret CreateSpotlight & RemoveSpotlight (Spotlight, Follow Me) +function AnimTasks.Spotlight(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_FRONT + local tx, ty = vm:battlerCenter(vm:resolveBattlerSide("target")) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Elliptical yellow spotlight cone shining down from top + love.graphics.setColor(1.00, 0.95, 0.60, a * 0.45) + love.graphics.polygon("fill", 120, -10, tx - 32, ty + 24, tx + 32, ty + 24) + love.graphics.ellipse("fill", tx, ty + 24, 32, 12) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.CreateSpotlight = AnimTasks.Spotlight +AnimTasks.REGISTRY.AnimTask_CreateSpotlight = AnimTasks.Spotlight +AnimTasks.REGISTRY.RemoveSpotlight = stub_task +AnimTasks.REGISTRY.AnimTask_RemoveSpotlight = stub_task + +--- pret MorningSunLightBeam & MoonlightEndFade +function AnimTasks.MorningSunLightBeam(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + -- Divine descending sunbeams + love.graphics.setColor(1.00, 0.98, 0.70, a * 0.50) + love.graphics.polygon("fill", ax - 10, -10, ax + 10, -10, ax + 36, ay + 30, ax - 36, ay + 30) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.MorningSunLightBeam = AnimTasks.MorningSunLightBeam +AnimTasks.REGISTRY.AnimTask_MorningSunLightBeam = AnimTasks.MorningSunLightBeam +AnimTasks.REGISTRY.MoonlightEndFade = stub_task + +--- pret GlareEyeDots (Glare) +function AnimTasks.GlareEyeDots(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 28 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 4) and (f / 4) or ((f >= dur - 4) and ((dur - f) / 4) or 1.0) + -- Piercing glowing crimson eyes + love.graphics.setColor(1.00, 0.15, 0.15, a * 0.95) + love.graphics.circle("fill", ax - 6, ay - 10, 3) + love.graphics.circle("fill", ax + 6, ay - 10, 3) + love.graphics.setColor(1.00, 0.85, 0.85, a * 0.90) + love.graphics.circle("fill", ax - 6, ay - 10, 1.2) + love.graphics.circle("fill", ax + 6, ay - 10, 1.2) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then + destroy_task(t) + end +end +AnimTasks.REGISTRY.GlareEyeDots = AnimTasks.GlareEyeDots +AnimTasks.REGISTRY.AnimTask_GlareEyeDots = AnimTasks.GlareEyeDots + +--- pret ElectricChargingParticles, DrillPeckHitSplats, GrudgeFlames, BarrageBall +function AnimTasks.GenericCombatFX(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.ElectricChargingParticles = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.DrillPeckHitSplats = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.GrudgeFlames = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.BarrageBall = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.StatusClearedEffect = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.RapinSpinMonElevation = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.ImprisonOrbs = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.SketchDrawMon = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.SkillSwap = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.Teleport = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.ConversionAlphaBlend = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.Conversion2AlphaBlend = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.RotateAuroraRingColors = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.AnimateGustTornadoPalette = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.MusicNotesClearRainbowBlend = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.LoadMusicNotesPals = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.FreeMusicNotesPals = AnimTasks.GenericCombatFX +AnimTasks.REGISTRY.AllocBackupPalBuffer = stub_task +AnimTasks.REGISTRY.FreeBackupPalBuffer = stub_task +AnimTasks.REGISTRY.CopyPalUnfadedFromBackup = stub_task +AnimTasks.REGISTRY.BlendBackground = stub_task +AnimTasks.REGISTRY.StartSinAnimTimer = stub_task +AnimTasks.REGISTRY.SetAttackerInvisibleWaitForSignal = stub_task +AnimTasks.REGISTRY.InitAttackerFadeFromInvisible = stub_task +AnimTasks.REGISTRY.VoltTackleAttackerReappear = stub_task + + +--- Subsystem D: Battler Movement & Metadata Evaluators + +function AnimTasks.AttackerPunchWithTrace(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + local Anim = require("src.core.game3.battle.anim") + local pAtk = Anim.present(vm:attackerSide()) + if frame < 10 and pAtk then + pAtk.ox = math.floor(math.sin((frame / 10) * math.pi) * 12) + elseif frame >= 10 and pAtk then + pAtk.ox = 0 + end + if frame >= dur then + if pAtk then pAtk.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.AttackerPunchWithTrace = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.HelpingHandAttackerMovement = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.TeeterDanceMovement = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.FlailMovement = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.OdorSleuthMovement = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.TormentAttacker = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.Rollout = AnimTasks.AttackerPunchWithTrace +AnimTasks.REGISTRY.LeafBlade = AnimTasks.AttackerPunchWithTrace + +--- Dynamic Metadata and Query Evaluators +function AnimTasks.QueryStateTask(t, vm) + destroy_task(t) +end +AnimTasks.REGISTRY.GetRolloutCounter = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetFuryCutterHitCount = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsFuryCutterHitRight = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetReturnPowerLevel = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetFrustrationPowerLevel = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetSeismicTossDamageLevel = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsPowerOver99 = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetBattleTerrain = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetWeather = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsContest = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsTargetPlayerSide = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetIsDoomDesireHitTurn = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsHealingMove = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsAttackerBehindSubstitute = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsBallBlockedByTrainerOrDodged = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsMonInvisible = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.IsTargetSameSide = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetBattlersFromArg = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.GetTrappedMoveAnimId = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SnatchOpposingMonMove = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SnatchPartnerMove = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SetAnimAttackerAndTargetForEffectAtk = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SetAnimAttackerAndTargetForEffectTgt = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SetAnimTargetToBattlerTarget = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SetTargetToEffectBattler = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SafariGetReaction = AnimTasks.QueryStateTask +AnimTasks.REGISTRY.SafariOrGhost_DecideAnimSides = AnimTasks.QueryStateTask + +-- ========================================================================= +-- Pret Alignment: Movement, Special Combat, Buffers & System Subroutines +-- ========================================================================= + +--- pret AnimTask_DigDownMovement & AnimTask_DigUpMovement (battle_anim_ground.c) +function AnimTasks.DigDownMovement(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + local isDisappear = (tonumber(t.data[0]) or 0) ~= 0 + if p then + if not isDisappear then + -- Bounce down into hole + local u = frame / dur + p.oy = math.floor(u * 32) + p.alpha = math.max(0, 1.0 - u * 1.2) + else + p.oy = 40 + p.invisible = true + end + end + if frame >= dur then + if p then p.invisible = true end + destroy_task(t) + end +end +AnimTasks.REGISTRY.DigDownMovement = AnimTasks.DigDownMovement +AnimTasks.REGISTRY.AnimTask_DigDownMovement = AnimTasks.DigDownMovement + +function AnimTasks.DigUpMovement(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + local isRise = (tonumber(t.data[0]) or 0) ~= 0 + if p then + p.invisible = false + if isRise then + local u = 1.0 - (frame / dur) + p.oy = math.floor(u * 32) + p.alpha = math.min(1.0, (frame / dur) * 1.5) + else + p.oy = 0 + p.alpha = 1.0 + end + end + if frame >= dur then + if p then p.oy = 0; p.alpha = 1.0; p.invisible = false end + destroy_task(t) + end +end +AnimTasks.REGISTRY.DigUpMovement = AnimTasks.DigUpMovement +AnimTasks.REGISTRY.AnimTask_DigUpMovement = AnimTasks.DigUpMovement + +--- pret AnimTask_SkullBashPosition (battle_anim_effects_1.c) +function AnimTasks.SkullBashPosition(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 16 + local Anim = require("src.core.game3.battle.anim") + local side = vm:attackerSide() + local p = Anim.present(side) + local mode = tonumber(t.data[0]) or 0 + local dir = (side == "player") and -1 or 1 + if p then + if mode == 0 then + -- Step back to charge + local u = math.min(1.0, frame / dur) + p.ox = math.floor(dir * 10 * u) + else + -- Rush forward + local u = math.min(1.0, frame / dur) + p.ox = math.floor(-dir * 16 * (1.0 - u)) + end + end + if frame >= dur then + if p and mode ~= 0 then p.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.SkullBashPosition = AnimTasks.SkullBashPosition +AnimTasks.REGISTRY.AnimTask_SkullBashPosition = AnimTasks.SkullBashPosition + +--- pret AnimTask_ExtremeSpeedImpact & AnimTask_ExtremeSpeedMonReappear (battle_anim_effects_2.c) +function AnimTasks.ExtremeSpeedImpact(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 18 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:targetSide()) + if p then + local phase = frame % 4 + local amp = math.max(1, 8 - math.floor(frame / 2)) + p.ox = (phase < 2) and amp or -amp + end + if frame >= dur then + if p then p.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.ExtremeSpeedImpact = AnimTasks.ExtremeSpeedImpact +AnimTasks.REGISTRY.AnimTask_ExtremeSpeedImpact = AnimTasks.ExtremeSpeedImpact + +function AnimTasks.ExtremeSpeedMonReappear(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 14 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + p.invisible = (frame % 2 == 1) + end + if frame >= dur then + if p then p.invisible = false end + destroy_task(t) + end +end +AnimTasks.REGISTRY.ExtremeSpeedMonReappear = AnimTasks.ExtremeSpeedMonReappear +AnimTasks.REGISTRY.AnimTask_ExtremeSpeedMonReappear = AnimTasks.ExtremeSpeedMonReappear + +--- pret AnimTask_AttackerStretchAndDisappear (battle_anim_effects_2.c) +function AnimTasks.AttackerStretchAndDisappear(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 18 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + local u = frame / dur + p.sy = 1.0 + u * 0.8 + p.sx = math.max(0.1, 1.0 - u * 0.8) + p.alpha = math.max(0, 1.0 - u) + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0; p.invisible = true end + destroy_task(t) + end +end +AnimTasks.REGISTRY.AttackerStretchAndDisappear = AnimTasks.AttackerStretchAndDisappear +AnimTasks.REGISTRY.AnimTask_AttackerStretchAndDisappear = AnimTasks.AttackerStretchAndDisappear + +--- pret AnimTask_SlideMonForFocusBand (battle_anim_effects_3.c) +function AnimTasks.SlideMonForFocusBand(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 16 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + local dir = (vm:attackerSide() == "player") and -1 or 1 + if p then + local u = math.sin((frame / dur) * math.pi) + p.ox = math.floor(dir * 10 * u) + end + if frame >= dur then + if p then p.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.SlideMonForFocusBand = AnimTasks.SlideMonForFocusBand +AnimTasks.REGISTRY.AnimTask_SlideMonForFocusBand = AnimTasks.SlideMonForFocusBand + +--- pret AnimTask_SpeedDust (battle_anim_effects_2.c) +function AnimTasks.SpeedDust(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = math.max(0, 1.0 - (f / dur)) + love.graphics.setColor(0.85, 0.80, 0.70, a * 0.75) + for i = 0, 3 do + local dx = (i * 12 - f * 1.5) + love.graphics.circle("fill", ax + dx, ay + 22, 3 + (f * 0.2)) + end + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.SpeedDust = AnimTasks.SpeedDust +AnimTasks.REGISTRY.AnimTask_SpeedDust = AnimTasks.SpeedDust + +--- pret AnimTask_ThrashMoveMonHorizontal & AnimTask_ThrashMoveMonVertical (battle_anim_effects_2.c) +function AnimTasks.ThrashMoveMonHorizontal(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + p.ox = math.floor(math.sin(frame * 0.8) * 12) + end + if frame >= dur then + if p then p.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.ThrashMoveMonHorizontal = AnimTasks.ThrashMoveMonHorizontal +AnimTasks.REGISTRY.AnimTask_ThrashMoveMonHorizontal = AnimTasks.ThrashMoveMonHorizontal + +function AnimTasks.ThrashMoveMonVertical(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + p.oy = math.floor(math.sin(frame * 0.8) * 8) + end + if frame >= dur then + if p then p.oy = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.ThrashMoveMonVertical = AnimTasks.ThrashMoveMonVertical +AnimTasks.REGISTRY.AnimTask_ThrashMoveMonVertical = AnimTasks.ThrashMoveMonVertical + +--- pret AnimTask_MoveHeatWaveTargets (battle_anim_fire.c) +function AnimTasks.MoveHeatWaveTargets(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:targetSide()) + if p then + p.ox = math.floor(math.sin(frame * 0.6) * 6) + end + if frame >= dur then + if p then p.ox = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.MoveHeatWaveTargets = AnimTasks.MoveHeatWaveTargets +AnimTasks.REGISTRY.AnimTask_MoveHeatWaveTargets = AnimTasks.MoveHeatWaveTargets + +--- pret AnimTask_DeepInhale (battle_anim_effects_3.c) +function AnimTasks.DeepInhale(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + local u = math.sin((frame / dur) * math.pi) + p.sx = 1.0 + u * 0.25 + p.sy = 1.0 + u * 0.15 + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.DeepInhale = AnimTasks.DeepInhale +AnimTasks.REGISTRY.AnimTask_DeepInhale = AnimTasks.DeepInhale + +--- pret AnimTask_WaterSpoutLaunch & AnimTask_WaterSpoutRain (battle_anim_water.c) +function AnimTasks.WaterSpoutLaunch(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.MID_FIELD + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local height = math.min(112, f * 6) + love.graphics.setColor(0.20, 0.55, 0.95, a * 0.80) + love.graphics.rectangle("fill", ax - 24, ay + 20 - height, 48, height) + love.graphics.setColor(0.70, 0.90, 1.00, a * 0.90) + love.graphics.rectangle("fill", ax - 16, ay + 20 - height, 32, height) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.WaterSpoutLaunch = AnimTasks.WaterSpoutLaunch +AnimTasks.REGISTRY.AnimTask_WaterSpoutLaunch = AnimTasks.WaterSpoutLaunch + +function AnimTasks.WaterSpoutRain(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + t.z = AnimSprites.Z.GLOBAL_FRONT + + if not t._particles then + t._particles = {} + for _ = 1, 24 do + t._particles[#t._particles + 1] = { + x = math.random(10, 230), + y = math.random(-30, 20), + vy = math.random(5, 9), + } + end + end + + for _, p in ipairs(t._particles) do + p.y = p.y + p.vy + if p.y > 115 then p.y = -15; p.x = math.random(10, 230) end + end + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + if not task._particles then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(0.35, 0.70, 1.00, a * 0.85) + love.graphics.setLineWidth(2) + for _, p in ipairs(task._particles) do + love.graphics.line(p.x, p.y, p.x, p.y + 12) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.WaterSpoutRain = AnimTasks.WaterSpoutRain +AnimTasks.REGISTRY.AnimTask_WaterSpoutRain = AnimTasks.WaterSpoutRain + +--- pret AnimTask_DoomDesireLightBeam (battle_anim_effects_3.c) +function AnimTasks.DoomDesireLightBeam(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 36 + t.z = AnimSprites.Z.GLOBAL_FRONT + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(1.00, 0.95, 0.40, a * 0.75) + love.graphics.polygon("fill", tx - 8, -10, tx + 8, -10, tx + 24, ty + 24, tx - 24, ty + 24) + love.graphics.setColor(1.00, 1.00, 0.90, a * 0.90) + love.graphics.polygon("fill", tx - 3, -10, tx + 3, -10, tx + 10, ty + 24, tx - 10, ty + 24) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.DoomDesireLightBeam = AnimTasks.DoomDesireLightBeam +AnimTasks.REGISTRY.AnimTask_DoomDesireLightBeam = AnimTasks.DoomDesireLightBeam + +--- pret AnimTask_AirCutterProjectile (battle_anim_effects_2.c) +function AnimTasks.AirCutterProjectile(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + local tx, ty = vm:battlerCenter("target") + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local u = math.min(1.0, f / dur) + local cx = ax + (tx - ax) * u + local cy = ay + (ty - ay) * u + local a = (f <= 4) and (f / 4) or ((f >= dur - 4) and ((dur - f) / 4) or 1.0) + love.graphics.setColor(0.70, 0.95, 0.90, a * 0.90) + love.graphics.setLineWidth(2) + love.graphics.arc("line", cx, cy, 18, -math.pi * 0.4, math.pi * 0.4) + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.AirCutterProjectile = AnimTasks.AirCutterProjectile +AnimTasks.REGISTRY.AnimTask_AirCutterProjectile = AnimTasks.AirCutterProjectile + +--- pret AnimTask_CreateSmallSolarBeamOrbs (battle_anim_effects_1.c) +function AnimTasks.CreateSmallSolarBeamOrbs(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + love.graphics.setColor(1.00, 0.90, 0.30, a * 0.85) + for i = 0, 7 do + local angle = (i / 8) * math.pi * 2 + (f * 0.1) + local r = math.max(4, 36 - (f * 1.0)) + local px = ax + math.cos(angle) * r + local py = ay + math.sin(angle) * r + love.graphics.circle("fill", px, py, 2.5) + end + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.CreateSmallSolarBeamOrbs = AnimTasks.CreateSmallSolarBeamOrbs +AnimTasks.REGISTRY.AnimTask_CreateSmallSolarBeamOrbs = AnimTasks.CreateSmallSolarBeamOrbs + +--- pret AnimTask_CurseStretchingBlackBg (battle_anim_ghost.c) +function AnimTasks.CurseStretchingBlackBg(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_BEHIND + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + local height = math.min(112, f * 4) + love.graphics.setColor(0.05, 0.02, 0.08, a * 0.75) + love.graphics.rectangle("fill", 0, 56 - height / 2, 240, height) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.CurseStretchingBlackBg = AnimTasks.CurseStretchingBlackBg +AnimTasks.REGISTRY.AnimTask_CurseStretchingBlackBg = AnimTasks.CurseStretchingBlackBg + +--- pret AnimTask_CastformGfxChange & AnimTask_MusicNotesRainbowBlend (battle_anim_effects_3.c & 1.c) +function AnimTasks.CastformGfxChange(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.CastformGfxChange = AnimTasks.CastformGfxChange +AnimTasks.REGISTRY.AnimTask_CastformGfxChange = AnimTasks.CastformGfxChange +AnimTasks.REGISTRY.MusicNotesRainbowBlend = AnimTasks.CastformGfxChange +AnimTasks.REGISTRY.AnimTask_MusicNotesRainbowBlend = AnimTasks.CastformGfxChange + +--- pret AnimTask_AlphaFadeIn (battle_anim_mons.c) +function AnimTasks.AlphaFadeIn(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = math.max(8, tonumber(t.data[0]) or 16) + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.AlphaFadeIn = AnimTasks.AlphaFadeIn +AnimTasks.REGISTRY.AnimTask_AlphaFadeIn = AnimTasks.AlphaFadeIn + +--- pret AnimTask_DrawFallingWhiteLinesOnAttacker (battle_anim_utility_funcs.c) +function AnimTasks.DrawFallingWhiteLinesOnAttacker(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + t.z = AnimSprites.Z.GLOBAL_FRONT + local ax, ay = vm:battlerCenter(vm:attackerSide()) + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 4) and (f / 4) or ((f >= dur - 4) and ((dur - f) / 4) or 1.0) + love.graphics.setColor(1, 1, 1, a * 0.85) + love.graphics.setLineWidth(1.5) + for i = -2, 2 do + local lx = ax + (i * 10) + local ly = (ay - 24 + f * 3 + i * 4) % 64 + (ay - 32) + love.graphics.line(lx, ly, lx, ly + 14) + end + love.graphics.setLineWidth(1) + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.DrawFallingWhiteLinesOnAttacker = AnimTasks.DrawFallingWhiteLinesOnAttacker +AnimTasks.REGISTRY.AnimTask_DrawFallingWhiteLinesOnAttacker = AnimTasks.DrawFallingWhiteLinesOnAttacker + +--- pret AnimTask_GrowAndGrayscale & AnimTask_ShrinkTargetCopy (battle_anim_effects_2.c & 1.c) +function AnimTasks.GrowAndGrayscale(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + local u = frame / dur + p.sx = 1.0 + u * 0.3 + p.sy = 1.0 + u * 0.3 + p.grayscale = u + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0; p.grayscale = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.GrowAndGrayscale = AnimTasks.GrowAndGrayscale +AnimTasks.REGISTRY.AnimTask_GrowAndGrayscale = AnimTasks.GrowAndGrayscale + +function AnimTasks.ShrinkTargetCopy(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:targetSide()) + if p then + local u = 1.0 - (frame / dur) + p.sx = math.max(0.1, u) + p.sy = math.max(0.1, u) + p.alpha = math.max(0, u) + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0; p.alpha = 1.0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.ShrinkTargetCopy = AnimTasks.ShrinkTargetCopy +AnimTasks.REGISTRY.AnimTask_ShrinkTargetCopy = AnimTasks.ShrinkTargetCopy + +--- pret AnimTask_StrongFrustrationGrowAndShrink & AnimTask_SquishAndSweatDroplets (battle_anim_effects_3.c) +function AnimTasks.StrongFrustrationGrowAndShrink(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 24 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + local u = math.sin((frame / dur) * math.pi) + p.sx = 1.0 + u * 0.35 + p.sy = 1.0 + u * 0.35 + p.blendColor = { 1.0, 0.2, 0.2 } + p.blendCoeff = u * 0.6 + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0; p.blendCoeff = 0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.StrongFrustrationGrowAndShrink = AnimTasks.StrongFrustrationGrowAndShrink +AnimTasks.REGISTRY.AnimTask_StrongFrustrationGrowAndShrink = AnimTasks.StrongFrustrationGrowAndShrink + +function AnimTasks.SquishAndSweatDroplets(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 20 + local Anim = require("src.core.game3.battle.anim") + local p = Anim.present(vm:attackerSide()) + if p then + local u = math.sin((frame / dur) * math.pi) + p.sy = 1.0 - u * 0.3 + p.sx = 1.0 + u * 0.2 + end + if frame >= dur then + if p then p.sx = 1.0; p.sy = 1.0 end + destroy_task(t) + end +end +AnimTasks.REGISTRY.SquishAndSweatDroplets = AnimTasks.SquishAndSweatDroplets +AnimTasks.REGISTRY.AnimTask_SquishAndSweatDroplets = AnimTasks.SquishAndSweatDroplets + +--- pret AnimTask_StartSlidingBg & AnimTask_StatsChange +function AnimTasks.StartSlidingBg(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.StartSlidingBg = AnimTasks.StartSlidingBg +AnimTasks.REGISTRY.AnimTask_StartSlidingBg = AnimTasks.StartSlidingBg + +function AnimTasks.StatsChange(t, vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 32 + t.z = AnimSprites.Z.GLOBAL_FRONT + local tx, ty = vm:battlerCenter("target") + local isBoost = (tonumber(t.data[0]) or 0) == 0 + + t.draw = function(task, _vm) + if not (love and love.graphics) then return end + local f = task.data[14] or 1 + local a = (f <= 6) and (f / 6) or ((f >= dur - 6) and ((dur - f) / 6) or 1.0) + if isBoost then + love.graphics.setColor(0.95, 0.30, 0.20, a * 0.70) + else + love.graphics.setColor(0.30, 0.40, 0.95, a * 0.70) + end + for i = -2, 2 do + local sx = tx + (i * 12) + local sy = isBoost and (ty + 20 - (f * 2.5 + i * 3) % 40) or (ty - 20 + (f * 2.5 + i * 3) % 40) + love.graphics.circle("fill", sx, sy, 3) + end + love.graphics.setColor(1, 1, 1, 1) + end + + if frame >= dur then destroy_task(t) end +end +AnimTasks.REGISTRY.StatsChange = AnimTasks.StatsChange +AnimTasks.REGISTRY.AnimTask_StatsChange = AnimTasks.StatsChange + +function AnimTasks.FakeOut(t, _vm) + local frame = t.data[14] or 0 + t.data[14] = frame + 1 + local dur = 16 + local Anim = require("src.core.game3.battle.anim") + if frame < 8 then + Anim.setScreenEffect({ type = "fade_black", coeff = 0.5 }) + else + Anim.setScreenEffect(nil) + end + if frame >= dur then + Anim.setScreenEffect(nil) + destroy_task(t) + end +end +AnimTasks.REGISTRY.FakeOut = AnimTasks.FakeOut +AnimTasks.REGISTRY.AnimTask_FakeOut = AnimTasks.FakeOut + +--- pret Palette Buffers & Generic System Tasks +AnimTasks.REGISTRY.CopyPalFadedToUnfaded = stub_task +AnimTasks.REGISTRY.AnimTask_CopyPalFadedToUnfaded = stub_task +AnimTasks.REGISTRY.CopyPalUnfadedToBackup = stub_task +AnimTasks.REGISTRY.AnimTask_CopyPalUnfadedToBackup = stub_task +AnimTasks.REGISTRY.SubstituteFadeToInvisible = stub_task +AnimTasks.REGISTRY.AnimTask_SubstituteFadeToInvisible = stub_task +AnimTasks.REGISTRY.SwapMonSpriteToFromSubstitute = stub_task +AnimTasks.REGISTRY.AnimTask_SwapMonSpriteToFromSubstitute = stub_task +AnimTasks.REGISTRY.SwitchOutBallEffect = stub_task +AnimTasks.REGISTRY.AnimTask_SwitchOutBallEffect = stub_task +AnimTasks.REGISTRY.SwitchOutShrinkMon = stub_task +AnimTasks.REGISTRY.AnimTask_SwitchOutShrinkMon = stub_task +AnimTasks.REGISTRY.ThrowBall = stub_task +AnimTasks.REGISTRY.AnimTask_ThrowBall = stub_task +AnimTasks.REGISTRY.ThrowBallSpecial = stub_task +AnimTasks.REGISTRY.AnimTask_ThrowBallSpecial = stub_task +AnimTasks.REGISTRY.LoadBallGfx = stub_task +AnimTasks.REGISTRY.AnimTask_LoadBallGfx = stub_task +AnimTasks.REGISTRY.FreeBallGfx = stub_task +AnimTasks.REGISTRY.AnimTask_FreeBallGfx = stub_task +AnimTasks.REGISTRY.LoadBaitGfx = stub_task +AnimTasks.REGISTRY.AnimTask_LoadBaitGfx = stub_task +AnimTasks.REGISTRY.FreeBaitGfx = stub_task +AnimTasks.REGISTRY.AnimTask_FreeBaitGfx = stub_task +AnimTasks.REGISTRY.GhostGetOut = stub_task +AnimTasks.REGISTRY.AnimTask_GhostGetOut = stub_task +AnimTasks.REGISTRY.FlashHealthboxOnLevelUp = stub_task +AnimTasks.REGISTRY.AnimTask_FlashHealthboxOnLevelUp = stub_task +AnimTasks.REGISTRY.LoadHealthboxPalsForLevelUp = stub_task +AnimTasks.REGISTRY.AnimTask_LoadHealthboxPalsForLevelUp = stub_task +AnimTasks.REGISTRY.FreeHealthboxPalsForLevelUp = stub_task +AnimTasks.REGISTRY.AnimTask_FreeHealthboxPalsForLevelUp = stub_task +AnimTasks.REGISTRY.SoundTask_PlayCryHighPitch = stub_task +AnimTasks.REGISTRY.SoundTask_PlayCryWithEcho = stub_task +AnimTasks.REGISTRY.SoundTask_PlaySE2WithPanning = stub_task + + function AnimTasks.spawn(name, priority, args, vm) AnimTasks.init() name = tostring(name or "stub") @@ -436,6 +4695,23 @@ function AnimTasks.update(vm) end end +function AnimTasks.draw(minZ, maxZ, vm) + if not (love and love.graphics) then return end + AnimTasks.init() + for i = 1, AnimTasks.MAX do + local t = AnimTasks._pool[i] + if t.active and t.draw then + local z = t.z or AnimSprites.Z.MID_FIELD + if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then + local ok, err = pcall(t.draw, t, vm) + if not ok then + print("[battle.anim] task draw " .. tostring(t.name) .. ": " .. tostring(err)) + end + end + end + end +end + --- Destroy helper for sprite callbacks / tasks that finish themselves. AnimTasks.destroy = destroy_task diff --git a/src/core/game3/battle/anim_vm.lua b/src/core/game3/battle/anim_vm.lua index 42873e02..6ebfd31c 100644 --- a/src/core/game3/battle/anim_vm.lua +++ b/src/core/game3/battle/anim_vm.lua @@ -165,25 +165,32 @@ function AnimVm.new() end function AnimVm:attackerSide() - return self._attackerSide + return self._attackerSide or "player" +end + +function AnimVm:targetSide() + return self._targetSide or (self._attackerSide == "player" and "enemy" or "player") end function AnimVm:resolveBattlerSide(token) - if token == nil then return self._targetSide end + if token == nil then return self:targetSide() end if type(token) == "number" then - -- pret ANIM_ATTACKER=0 ANIM_TARGET=1 style - if token == 0 then return self._attackerSide end - return self._targetSide + if token == 0 then return self:attackerSide() + elseif token == 1 then return self:targetSide() + else return nil end -- In singles, partners (2, 3) and values >= 2 do not exist end local s = tostring(token):lower() if s == "attacker" or s == "anim_attacker" or s == "0" then - return self._attackerSide + return self:attackerSide() end if s == "target" or s == "anim_target" or s == "1" then - return self._targetSide + return self:targetSide() end if s == "player" or s == "enemy" then return s end - return self._targetSide + if s == "atk_partner" or s == "anim_atk_partner" or s == "def_partner" or s == "anim_def_partner" then + return nil -- singles has no partner + end + return self:targetSide() end function AnimVm:x(v) @@ -216,6 +223,9 @@ function AnimVm:reset() self.callStack = {} self.framesToWait = 0 self.waitingVisual = false + self.waitingSprites = false + self._visualWaitFrames = 0 + self._spriteWaitFrames = 0 self.loadedTags = {} self._onEnd = nil for i = 0, ARG_COUNT - 1 do self.args[i] = 0 end @@ -226,6 +236,9 @@ end local function finish(self) self.active = false self.waitingVisual = false + self.waitingSprites = false + self._visualWaitFrames = 0 + self._spriteWaitFrames = 0 self.framesToWait = 0 local cb = self._onEnd self._onEnd = nil @@ -256,7 +269,9 @@ function AnimVm:launch(script, opts) self._onEnd = opts.onEnd self.framesToWait = 0 self.waitingVisual = false + self.waitingSprites = false self._visualWaitFrames = 0 + self._spriteWaitFrames = 0 return true end @@ -309,27 +324,36 @@ end function AnimVm:draw(minZ, maxZ) if not (love and love.graphics) then return end + + -- 1. Render active visual tasks at this Z layer + if AnimTasks and AnimTasks.draw then + AnimTasks.draw(minZ, maxZ, self) + end + + -- 2. Render particle sprites local list = AnimSprites.sortedDrawList(self._drawList, minZ, maxZ) local activeBlend = "alpha" for _, s in ipairs(list) do - if s.image then + 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 + + if s.customDraw then + s:customDraw(self) + elseif s.image then 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 flipX = s.hFlip and -1 or 1 local flipY = s.vFlip and -1 or 1 @@ -376,20 +400,45 @@ local function run_createsprite(vm, op) local cbName = op.callback or (info and info.callback) local noGfx = op.noGfx or (info and info.noGfx) - -- Invisible helper templates → visual tasks (pret sprite CB moves battler) + -- Invisible helper templates → visual tasks (pret sprite CB moves battler / blends pal) if noGfx or cbName == "HorizontalLunge" or cbName == "DoHorizontalLunge" + or cbName == "ReverseHorizontalLungeDirection" or cbName == "VerticalDip" or cbName == "DoVerticalDip" + or cbName == "ReverseVerticalDipDirection" or cbName == "SlideMonToOffset" or cbName == "SlideMonToOriginalPos" - or cbName == "BowMon" or cbName == "ShakeMonOrBattleTerrain" then - local taskName = cbName or "HorizontalLunge" - if taskName == "HorizontalLunge" or taskName == "DoHorizontalLunge" then + or cbName == "SlideMonToOffsetAndBack" + or cbName == "BowMon" or cbName == "AnimBowMon" + or cbName == "ShakeMonOrBattleTerrain" or cbName == "AnimShakeMonOrBattleTerrain" + or cbName == "SimplePaletteBlend" or cbName == "AnimSimplePaletteBlend" + or cbName == "ComplexPaletteBlend" or cbName == "AnimComplexPaletteBlend" + or template == "gSimplePaletteBlendSpriteTemplate" + or template == "gComplexPaletteBlendSpriteTemplate" then + local taskName = cbName + if not taskName or taskName == "" then + return + end + if taskName == "HorizontalLunge" or taskName == "DoHorizontalLunge" or taskName == "ReverseHorizontalLungeDirection" then AnimTasks.spawn("HorizontalLunge", 2, { args[1] or 4, args[2] or 4 }, vm) - elseif taskName == "SlideMonToOffset" or taskName == "SlideMonToOriginalPos" then - AnimTasks.spawn("SlideMon", 2, args, vm) - elseif taskName == "BowMon" then + elseif taskName == "VerticalDip" or taskName == "DoVerticalDip" or taskName == "ReverseVerticalDipDirection" then + AnimTasks.spawn("VerticalDip", 2, { args[1] or 4, args[2] or 4, args[3] or 0 }, vm) + elseif taskName == "SlideMonToOriginalPos" then + AnimTasks.spawn("SlideMonToOriginalPos", 2, args, vm) + elseif taskName == "SlideMonToOffset" then + AnimTasks.spawn("SlideMonToOffset", 2, args, vm) + elseif taskName == "SlideMonToOffsetAndBack" then + AnimTasks.spawn("SlideMonToOffsetAndBack", 2, args, vm) + elseif taskName == "BowMon" or taskName == "AnimBowMon" then AnimTasks.spawn("BowMon", 2, args, vm) - elseif taskName == "ShakeMonOrBattleTerrain" then + elseif taskName == "ShakeMonOrBattleTerrain" or taskName == "AnimShakeMonOrBattleTerrain" then AnimTasks.spawn("ShakeMonOrBattleTerrain", 2, args, vm) + elseif taskName == "SimplePaletteBlend" or taskName == "AnimSimplePaletteBlend" + or template == "gSimplePaletteBlendSpriteTemplate" then + AnimTasks.spawn("BlendBattleAnimPal", 2, args, vm) + elseif taskName == "ComplexPaletteBlend" or taskName == "AnimComplexPaletteBlend" + or template == "gComplexPaletteBlendSpriteTemplate" then + AnimTasks.spawn("ComplexPaletteBlend", 2, args, vm) + elseif taskName == "GrantingStars" and noGfx then + AnimTasks.spawn("BlendBattleAnimPal", 2, args, vm) else AnimTasks.spawn(taskName, 2, args, vm) end @@ -398,7 +447,7 @@ 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 + if not img and vm.getImpactFallback then img = vm:getImpactFallback() end @@ -498,9 +547,29 @@ local function run_createsprite(vm, op) 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 isBehindLayer = (cbName == "MudSportDirt" or cbName == "MudSlap" or cbName == "MudShot" + or cbName == "WaterSport" or cbName == "ShadowBall" or cbName == "Spikes" + or cbName == "FrenzyPlantRoot" or cbName == "IngrainRoot") + local isForegroundLayer = (cbName == "BasicFistOrFoot" or cbName == "SpinningKickOrPunch" + or cbName == "SlidingKick" or cbName == "JumpKick" or cbName == "StompFoot" + or cbName == "CrossChopHand" or cbName == "Lightning" or cbName == "ElectricBolt" + or cbName == "VoltTackleBolt" or cbName == "HitSplatBasic" or cbName == "HitSplatRandom" + or cbName == "HitSplatHandleInvert" or cbName == "CrossImpact" or cbName == "FlashingHitSplat" + or cbName == "CuttingSlice" or cbName == "AirCutterSlice" or cbName == "SlashSlice" + or cbName == "Bite" or cbName == "Fang" or cbName == "SuperFang") + + local zDepth + if op.z or op.depth then + zDepth = tonumber(op.z or op.depth) + elseif isBehindLayer then + zDepth = AnimSprites.Z.GLOBAL_BEHIND + elseif isForegroundLayer then + zDepth = AnimSprites.Z.GLOBAL_FRONT + else + local layer = "front" + zDepth = AnimSprites.slotZ(anchorSide, layer) + (tonumber(op.subpriority) or 0) + end + local blendMode = isBarrierShield and "add" or "alpha" local spr = AnimSprites.acquire({ @@ -564,6 +633,9 @@ local function run_op(vm, op) elseif code == "waitforvisualfinish" then vm.waitingVisual = true return "wait" + elseif code == "waitanimation" or code == "waitsprites" or code == "waitforsprites" then + vm.waitingSprites = true + return "wait" elseif code == "nop" or code == "nop2" then return "ok" elseif code == "end" then @@ -575,7 +647,7 @@ local function run_op(vm, op) return "ok" elseif code == "loopsewithpan" then local id = op.song or op.se or op.id or op[1] - spawn_loop_se(vm, id, op.pan, op.wait or op.frames or 10, op.plays or op.count or 1) + spawn_loop_se(vm, id, op.pan, op.wait or op.frames or 10, op.plays or op.count or op.times or 1) return "ok" elseif code == "waitplaysewithpan" then local id = op.song or op.se or op.id or op[1] @@ -628,6 +700,49 @@ local function run_op(vm, op) local id = tonumber(op.argId) or 0 vm.args[id] = tonumber(op.value) or 0 return "ok" + elseif code == "choosetwoturnanim" then + local targetLabel = (vm._turn and (vm._turn % 2 == 1)) and op.label2 or (op.label1 or op.label2) + local sub = vm._pack and vm._pack.labels and vm._pack.labels[targetLabel] + if sub then + vm.script = sub + vm.pc = 0 + end + return "ok" + elseif code == "jumpifmoveturn" then + if (vm._turn or 1) == op.turn then + local sub = vm._pack and vm._pack.labels and vm._pack.labels[op.label] + if sub then + vm.script = sub + vm.pc = 0 + end + end + return "ok" + elseif code == "jumpargeq" then + local id = tonumber(op.argId) or 0 + if (vm.args[id] or 0) == (tonumber(op.value) or 0) then + local sub = vm._pack and vm._pack.labels and vm._pack.labels[op.label] + if sub then + vm.script = sub + vm.pc = 0 + end + end + return "ok" + elseif code == "panse" then + local id = op.song or op.se or op.id or op[1] + play_se_pan(vm, id, op.pan) + return "ok" + elseif code == "fadetobg" or code == "changebg" or code == "fadetobgfromset" then + local bgId = op.bg or op.bg1 or 0 + AnimTasks.spawn("LoadBattleBgImage", 2, { bgId }, vm) + return "ok" + elseif code == "restorebg" then + return "ok" + elseif code == "splitbgprio" then + local Anim = require("src.core.game3.battle.anim") + local side = vm:resolveBattlerSide(op.battler or "target") + local p = Anim.present(side) + if p then p.z = AnimVm.Z.FRONT end + return "ok" elseif code == "invisible" or code == "visible" then local Anim = require("src.core.game3.battle.anim") local side = vm:resolveBattlerSide(op.battler or "attacker") @@ -659,11 +774,8 @@ function AnimVm:update(_dt) if self.waitingVisual then self._visualWaitFrames = (self._visualWaitFrames or 0) + 1 local tasks = AnimTasks.activeCount() - local sprs = AnimSprites.activeCount() - if (tasks == 0 and sprs == 0) or self._visualWaitFrames > 180 then - if self._visualWaitFrames > 180 then - -- Force-clear stuck particles/tasks so scripts cannot soft-lock - AnimSprites.reset() + if tasks == 0 or self._visualWaitFrames > 600 then + if self._visualWaitFrames > 600 then AnimTasks.reset() end self.waitingVisual = false @@ -675,9 +787,25 @@ function AnimVm:update(_dt) self._visualWaitFrames = 0 end + if self.waitingSprites then + self._spriteWaitFrames = (self._spriteWaitFrames or 0) + 1 + local sprs = AnimSprites.activeCount() + if sprs == 0 or self._spriteWaitFrames > 600 then + if self._spriteWaitFrames > 600 then + AnimSprites.reset() + end + self.waitingSprites = false + self._spriteWaitFrames = 0 + else + return + end + else + self._spriteWaitFrames = 0 + end + -- Run opcodes until wait or end (pret style burst) local guard = 0 - while self.active and self.framesToWait <= 0 and not self.waitingVisual and guard < 64 do + while self.active and self.framesToWait <= 0 and not self.waitingVisual and not self.waitingSprites and guard < 64 do guard = guard + 1 local op = self.script and self.script[self.pc] if not op then diff --git a/src/core/game3/battle/learn_move.lua b/src/core/game3/battle/learn_move.lua index 1887afed..d695c66c 100644 --- a/src/core/game3/battle/learn_move.lua +++ b/src/core/game3/battle/learn_move.lua @@ -17,7 +17,6 @@ LearnMove._askForget = nil LearnMove._onDone = nil LearnMove._headless = false LearnMove._waitingChoice = false -LearnMove._pending = nil -- "delete" | "stop" | "forget" LearnMove._forgetSlots = nil function LearnMove.reset() @@ -31,8 +30,6 @@ function LearnMove.reset() LearnMove._askForget = nil LearnMove._onDone = nil LearnMove._waitingChoice = false - LearnMove._pending = nil - LearnMove._pendingResult = false LearnMove._forgetSlots = nil LearnMove._battleText = false LearnMove._queue = nil @@ -54,7 +51,6 @@ local function finish(learned) LearnMove._mon = nil LearnMove._moveId = nil LearnMove._waitingChoice = false - LearnMove._pending = nil LearnMove._onDone = nil -- Leave _queue / _queueOpts for beginQueue's onDone → queue_next if cb then cb(learned == true) end @@ -231,31 +227,6 @@ end --- Call when message queue is idle. Opens deferred Choice prompts. -- Returns false while still busy. function LearnMove.pump() - if not LearnMove._active then return true end - if LearnMove._waitingChoice then return false end - local pending = LearnMove._pending - if not pending then - return false -- still active, waiting for finish from choice path or free teach - end - -- For "done": hold until the "X learned Y!" message has drained from the - -- battle dialog (i.e. the player pressed A to dismiss it). - if pending == "done" then - local Ui = package.loaded["src.core.game3.battle.ui"] - if Ui and Ui.dialogPending and Ui.dialogPending() then - return false -- message still on screen, keep waiting - end - LearnMove._pending = nil - finish(LearnMove._pendingResult) - return not LearnMove._active - end - LearnMove._pending = nil - if pending == "delete" then - open_delete_prompt() - elseif pending == "stop" then - open_stop_prompt() - elseif pending == "forget" then - open_forget_list() - end return not LearnMove._active end @@ -284,7 +255,6 @@ function LearnMove.begin(opts) LearnMove._headless = opts.headless and true or false LearnMove._battleText = opts.battleText and true or false LearnMove._waitingChoice = false - LearnMove._pending = nil if Pokemon.moveSlotCount(mon) < 4 then local ok = Pokemon.teachMove(mon, moveId) @@ -294,13 +264,8 @@ function LearnMove.begin(opts) say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!") finish(ok) else - LearnMove._pendingResult = ok - LearnMove._pending = "done" say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!", function() - if LearnMove._active and LearnMove._pending == "done" then - LearnMove._pending = nil - finish(ok) - end + finish(ok) end) end else diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index e481efcc..15707118 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -483,8 +483,9 @@ local function draw_mon_sprite(battler, base, back) local hFlip = (pres and pres.hFlip) and true or false local sx = (hFlip and -1 or 1) * scale * ((pres and pres.sx) or 1) local sy = scale * ((pres and pres.sy) or 1) + local rot = (pres and pres.rotation) or 0 local blended = BallOpen.setBlendShader(BallOpen.monBlend(side)) - love.graphics.draw(entry.image, cx, cy, 0, sx, sy, 32, 32) + love.graphics.draw(entry.image, cx, cy, rot, sx, sy, 32, 32) if blended then love.graphics.setShader() end else -- Placeholder silhouette so lunge/shake is visible before full pic extract. @@ -498,9 +499,21 @@ local function draw_mon_sprite(battler, base, back) love.graphics.setColor(0.95, 0.45, 0.35, a) end local hw = 24 * scale - love.graphics.rectangle("fill", cx - hw, cy - hw, hw * 2, hw * 2) - love.graphics.setColor(1, 1, 1, a * 0.9) - love.graphics.rectangle("line", cx - hw, cy - hw, hw * 2, hw * 2) + local rot = (pres and pres.rotation) or 0 + if rot ~= 0 or sx ~= 1 or sy ~= 1 then + love.graphics.push() + love.graphics.translate(cx, cy) + love.graphics.rotate(rot) + love.graphics.scale(sx, sy) + love.graphics.rectangle("fill", -hw, -hw, hw * 2, hw * 2) + love.graphics.setColor(1, 1, 1, a * 0.9) + love.graphics.rectangle("line", -hw, -hw, hw * 2, hw * 2) + love.graphics.pop() + else + love.graphics.rectangle("fill", cx - hw, cy - hw, hw * 2, hw * 2) + love.graphics.setColor(1, 1, 1, a * 0.9) + love.graphics.rectangle("line", cx - hw, cy - hw, hw * 2, hw * 2) + end end end @@ -710,13 +723,16 @@ function Ui.draw(w, h) love.graphics.setColor(1, 1, 1, 1) end + local screenFxActive = Anim.beginScreenEffect and Anim.beginScreenEffect() + -- pokefirered/src/battle_anim_special.c:1888 - local bgBlended = BallOpen.setBlendShader(BallOpen.bgCoeff(), 31, 31, 31) + local bgBlended = not screenFxActive and BallOpen.setBlendShader(BallOpen.bgCoeff(), 31, 31, 31) 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 if bgBlended then love.graphics.setShader() end + if screenFxActive then Anim.beginScreenEffect() end love.graphics.setColor(1, 1, 1, 1) -- pret-ish 5-layer z: @@ -730,16 +746,19 @@ function Ui.draw(w, h) if st then draw_mon_sprite(st.enemy, ENEMY_MON, false) end + if screenFxActive then Anim.beginScreenEffect() end Anim.drawParticles(101, 199) -- pokefirered/src/battle_anim_mons.c:1908 draw_player_trainer(stage) if st then draw_mon_sprite(st.player, PLAYER_MON, true) end + if screenFxActive then Anim.beginScreenEffect() end Anim.drawParticles(201, 999) draw_intro_ball(stage) -- pokefirered/src/pokeball.c:770 BallOpen.draw() + if screenFxActive then Anim.endScreenEffect() end if st then Healthbox.draw("enemy", st.enemy) Healthbox.draw("player", st.player) diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index ac49c611..97215009 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -119,6 +119,11 @@ function Field.update(_dt) SpecialAnim.update() end + local okStep, StepEvents = pcall(require, "src.core.game3.step_events") + if okStep and StepEvents and StepEvents.update then + StepEvents.update(_dt, game) + end + if Message and Message.tick then Message.tick() end end diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index cffe4a05..4b81e816 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -624,4 +624,33 @@ function FieldEffects.drawOverlay(camX, camY) if ok and Heal and Heal.draw then Heal.draw(camX, camY) end end +--- pret dofieldeffect / waitfieldeffect for FLDEFF_POKECENTER_HEAL (25). +function FieldEffects.doFieldEffect(id) + id = tonumber(id) or 0 + local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") + if ok and Heal and id == Heal.FLDEFF then + return Heal.start() + end + return false +end + +function FieldEffects.waitFieldEffect(id, done) + id = tonumber(id) or 0 + local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") + if ok and Heal and id == Heal.FLDEFF then + Heal.wait(done) + return + end + if done then done() end +end + +function FieldEffects.isFieldEffectActive(id) + id = tonumber(id) or 0 + local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") + if ok and Heal and id == Heal.FLDEFF then + return Heal.isActive() + end + return false +end + return FieldEffects diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index 65f871cb..a2d6ce30 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -411,12 +411,29 @@ function ItemUse.useField(session, bag, id, partySlot) return true, "repel", t end + if use == "vs_seeker" or id == ItemsData.ITEM_VS_SEEKER or id == "VS_SEEKER" + or ItemsData.toNumericId(id) == ItemsData.ITEM_VS_SEEKER then + local VsSeeker = require("src.core.game3.vs_seeker") + local ok = VsSeeker.use(session, nil) + return ok, "vs_seeker", nil + end + + if id == ItemsData.ITEM_TM_CASE or id == "TM_CASE" + or ItemsData.toNumericId(id) == ItemsData.ITEM_TM_CASE then + local TmCase = require("src.ui.game3.tm_case") + TmCase.show(session, bag) + return true, "tm_case", "Opened TM CASE." + end + + if id == ItemsData.ITEM_BERRY_POUCH or id == "BERRY_POUCH" + or ItemsData.toNumericId(id) == ItemsData.ITEM_BERRY_POUCH then + local BerryPouch = require("src.ui.game3.berry_pouch") + BerryPouch.show(session, bag) + return true, "berry_pouch", "Opened BERRY POUCH." + end + if use == "key" or use == "rod" or use == "berry" or use == "mail" or use == "flute" or use == "none" then - if ItemsData.toNumericId(id) == ItemsData.ITEM_TM_CASE - or ItemsData.toNumericId(id) == ItemsData.ITEM_BERRY_POUCH then - return false, "open_pocket", "Opened pocket." - end local t = "OAK: This isn't the\ntime to use that!" return false, use, t end diff --git a/src/core/game3/items_data.lua b/src/core/game3/items_data.lua index 60554d99..6414a1c5 100644 --- a/src/core/game3/items_data.lua +++ b/src/core/game3/items_data.lua @@ -15,6 +15,12 @@ ItemsData.POCKET_ORDER = { "ITEMS", "KEY_ITEMS", "POKE_BALLS", "TM_CASE", "BERRY_POUCH", } +-- Visible pockets in the main Bag UI (pret BAG_POCKETS_COUNT = 3). +-- TM Case and Berry Pouch are sub-containers accessed via Key Items. +ItemsData.BAG_POCKET_ORDER = { + "ITEMS", "KEY_ITEMS", "POKE_BALLS", +} + ItemsData.POCKET_LABEL = { ITEMS = "ITEMS", KEY_ITEMS = "KEY ITEMS", @@ -42,6 +48,7 @@ ItemsData.CAPACITY = { ItemsData.ITEM_TM_CASE = 364 ItemsData.ITEM_BERRY_POUCH = 365 +ItemsData.ITEM_VS_SEEKER = 374 ItemsData.FIRST_TM = 289 ItemsData.LAST_TM = 338 ItemsData.FIRST_HM = 339 @@ -100,6 +107,7 @@ ItemsData.BY_HOST = { BICYCLE = { name = "BICYCLE", pocket = "KEY_ITEMS", fieldUse = "bike", frlg = 360 }, TRI_PASS = { name = "TRI-PASS", pocket = "KEY_ITEMS", fieldUse = "key", frlg = 367 }, RAINBOW_PASS = { name = "RAINBOW PASS", pocket = "KEY_ITEMS", fieldUse = "key", frlg = 368 }, + VS_SEEKER = { name = "VS SEEKER", pocket = "KEY_ITEMS", fieldUse = "vs_seeker", frlg = 374 }, } local FALLBACK = { @@ -108,6 +116,7 @@ local FALLBACK = { [13] = { name = "POTION", pocket = "ITEMS", fieldUse = "heal" }, [364] = { name = "TM CASE", pocket = "KEY_ITEMS", fieldUse = "key" }, [365] = { name = "BERRY POUCH", pocket = "KEY_ITEMS", fieldUse = "key" }, + [374] = { name = "VS SEEKER", pocket = "KEY_ITEMS", fieldUse = "vs_seeker" }, } ItemsData.HEAL_AMOUNT = { @@ -155,6 +164,52 @@ local function read_bytes(rel) return nil end +ItemsData._byName = nil + +local GEN2_BERRY_ALIASES = { + BERRY = 139, -- ORAN BERRY + GOLD_BERRY = 142, -- SITRUS BERRY + GOLDBERRY = 142, + MYSTERYBERRY = 138, -- LEPPA BERRY + MIRACLEBERRY = 141, -- LUM BERRY + PSNCUREBERRY = 135, -- PECHA BERRY + PRZCUREBERRY = 133, -- CHERI BERRY + BURNT_BERRY = 136, -- RAWST BERRY + ICE_BERRY = 137, -- ASPEAR BERRY + BITTER_BERRY = 140, -- PERSIM BERRY + MINT_BERRY = 134, -- CHESTO BERRY +} + +local function build_by_name(packItems) + local map = {} + if type(packItems) == "table" then + for id, it in pairs(packItems) do + if type(it) == "table" and it.name then + local n = it.name:upper() + map[n] = id + map[n:gsub("%s+", "_")] = id + map[n:gsub("[^%w]", "")] = id + end + end + end + for k, v in pairs(GEN2_BERRY_ALIASES) do + map[k] = v + end + for i = 1, 50 do + map[string.format("TM%02d", i)] = 288 + i + map[string.format("TM_%02d", i)] = 288 + i + map[string.format("TM%d", i)] = 288 + i + map[string.format("TM_%d", i)] = 288 + i + end + for i = 1, 8 do + map[string.format("HM%02d", i)] = 338 + i + map[string.format("HM_%02d", i)] = 338 + i + map[string.format("HM%d", i)] = 338 + i + map[string.format("HM_%d", i)] = 338 + i + end + return map +end + local function load_pack() if ItemsData._byId then return ItemsData._byId end local src = read_bytes("data/generated/gba/items/pack.lua") @@ -165,6 +220,7 @@ local function load_pack() if ok and type(pack) == "table" and type(pack.items) == "table" then ItemsData._pack = pack ItemsData._byId = pack.items + ItemsData._byName = build_by_name(pack.items) if not ItemsData._logged then ItemsData._logged = true print("[game3/items] pack ready (" .. tostring(pack.count) .. ")") @@ -174,6 +230,7 @@ local function load_pack() end end ItemsData._byId = FALLBACK + ItemsData._byName = build_by_name(FALLBACK) if not ItemsData._logged then ItemsData._logged = true print("[game3/items] pack missing — using fallback rows") @@ -184,6 +241,7 @@ end function ItemsData.install(_cache) ItemsData._pack = nil ItemsData._byId = nil + ItemsData._byName = nil ItemsData._logged = false load_pack() end @@ -196,6 +254,24 @@ local function normalize_id(id) end local num = tonumber(id) if num then return num, tostring(num) end + + load_pack() + local sUpper = s:upper() + if ItemsData._byName and ItemsData._byName[sUpper] then + return ItemsData._byName[sUpper], s + end + + local tm = sUpper:match("^TM_?(%d+)$") + if tm then + local n = tonumber(tm) + if n and n >= 1 and n <= 50 then return 288 + n, s end + end + local hm = sUpper:match("^HM_?(%d+)$") + if hm then + local n = tonumber(hm) + if n and n >= 1 and n <= 8 then return 338 + n, s end + end + return nil, s end @@ -244,6 +320,13 @@ function ItemsData.info(id) end return { id = num, name = "ITEM " .. num, pocket = "ITEMS", fieldUse = "none" } end + local sUpper = s:upper() + if sUpper:find("BERRY", 1, true) then + return { id = s, name = s:gsub("_", " "), pocket = "BERRY_POUCH", fieldUse = "heal" } + end + if sUpper:find("^TM%d") or sUpper:find("^HM%d") or sUpper:find("^TM_") or sUpper:find("^HM_") then + return { id = s, name = s:gsub("_", " "), pocket = "TM_CASE", fieldUse = "tm" } + end return { id = s, name = s:gsub("_", " "), pocket = "ITEMS", fieldUse = "none" } end @@ -307,6 +390,51 @@ function ItemsData.isHm(id) return num and num >= ItemsData.FIRST_HM and num <= ItemsData.LAST_HM end +ItemsData.FIRST_BERRY = 133 +ItemsData.LAST_BERRY = 175 + +function ItemsData.isBerry(id) + local num = tonumber(id) + if not num then + num = ItemsData.toNumericId(id) + end + return num and num >= ItemsData.FIRST_BERRY and num <= ItemsData.LAST_BERRY +end + +--- Get 1-based Berry index (1..43) from item ID. +function ItemsData.berryNumber(id) + local num = tonumber(id) + if not num then + num = ItemsData.toNumericId(id) + end + if num and num >= ItemsData.FIRST_BERRY and num <= ItemsData.LAST_BERRY then + return num - ItemsData.FIRST_BERRY + 1 + end + return 1 +end + +--- Get 1-based TM (1..50) or HM (1..8) index from item ID. +function ItemsData.tmNumber(id) + local num = tonumber(id) + if not num then + num = ItemsData.toNumericId(id) + end + if num then + if num >= ItemsData.FIRST_TM and num <= ItemsData.LAST_TM then + return num - ItemsData.FIRST_TM + 1 + elseif num >= ItemsData.FIRST_HM and num <= ItemsData.LAST_HM then + return num - ItemsData.FIRST_HM + 1 + end + end + local s = tostring(id):upper() + local tm = s:match("TM_?(%d+)") + if tm then return tonumber(tm) end + local hm = s:match("HM_?(%d+)") + if hm then return tonumber(hm) end + return nil +end + + --- Canonical bag key: prefer numeric FRLG id string. function ItemsData.bagKey(id) local num = select(1, normalize_id(id)) @@ -363,7 +491,7 @@ local PP_IDS = { [34] = true, [35] = true, [36] = true, [37] = true, [69] = true local ESCAPE_IDS = { [85] = true } local REPEL_IDS = { [83] = true, [84] = true, [86] = true } local BIKE_IDS = { [259] = true, [260] = true } -local MAP_IDS = { [261] = true, [365] = true } +local MAP_IDS = { [261] = true, [361] = true, [365] = true } --- Effective field-use kind (medicine refined). function ItemsData.fieldUseKind(id) diff --git a/src/core/game3/m4a_mix.lua b/src/core/game3/m4a_mix.lua index 62380f02..1e618436 100644 --- a/src/core/game3/m4a_mix.lua +++ b/src/core/game3/m4a_mix.lua @@ -224,15 +224,18 @@ function Mix.cgbNoisePeriod(key) end --- Attach MP2K ADSR. CGB uses 0..15 sustain; DS uses 0..255. -function Mix.attachAdsr(voice, tone, isCgb) +function Mix.attachAdsr(voice, tone, isCgb, opts) if not voice then return voice end tone = tone or {} + opts = opts or {} voice.adsr = { attack = tonumber(tone.attack) or (isCgb and 0 or 255), decay = tonumber(tone.decay) or 0, sustain = tonumber(tone.sustain) or (isCgb and 15 or 255), release = tonumber(tone.release) or 0, isCgb = isCgb and true or false, + pseudoEchoVolume = tonumber(opts.pseudoEchoVolume or tone.pseudoEchoVolume or 0) or 0, + pseudoEchoLength = tonumber(opts.pseudoEchoLength or tone.pseudoEchoLength or 0) or 0, } voice.envPhase = "attack" voice.envVol = 0 @@ -316,12 +319,22 @@ function Mix.tickEnvelope(v) v.alive = false else v.envVol = math.floor(((v.envVol or 0) * (a.release or 0)) / 256) - if v.envVol <= 0 then + local peVol = a.pseudoEchoVolume or 0 + if peVol > 0 and (v.envVol or 0) <= peVol then + v.envVol = peVol + v.envPhase = "echo" + elseif (v.envVol or 0) <= 0 then v.envVol = 0 v.alive = false end end end + elseif phase == "echo" then + a.pseudoEchoLength = (a.pseudoEchoLength or 0) - 1 + if a.pseudoEchoLength <= 0 then + v.envVol = 0 + v.alive = false + end end local maxv = a.isCgb and 15 or 255 if maxv < 1 then maxv = 1 end @@ -455,14 +468,22 @@ local function s8_at(pcm, idx) end --- Linear interpolate s8 PCM (reduces stair-step harshness on upsample). -local function s8_lerp(pcm, pos, size) +local function s8_lerp(pcm, pos, size, loop, loopStart) if pos < 0 then return 0 end local i0 = math.floor(pos) if i0 >= size then return 0 end local frac = pos - i0 local s0 = s8_at(pcm, i0) - if frac < 1e-6 or i0 + 1 >= size then return s0 end - local s1 = s8_at(pcm, i0 + 1) + if frac < 1e-6 then return s0 end + local i1 = i0 + 1 + if i1 >= size then + if loop and loopStart and loopStart < size then + i1 = loopStart + else + return s0 + end + end + local s1 = s8_at(pcm, i1) return s0 + (s1 - s0) * frac end @@ -504,20 +525,30 @@ local function render_voice(v, n, outL, outR) if env == nil then env = 1 end if env <= 0 then return end if v.kind == "ds" then + local pcm = v.pcm + local size = v.size or #pcm + local loop = v.loop and true or false + local loopStart = (v.loopStart and v.loopStart >= 0 and v.loopStart < size) and v.loopStart or 0 + local loopLen = size - loopStart + local step = v.step or 1 + local pos = v.pos or 0 + local volL = (v.volL or 0.5) * 0.45 + local volR = (v.volR or 0.5) * 0.45 for i = 1, n do - if v.pos >= v.size then - if v.loop and v.loopStart < v.size then - v.pos = v.loopStart + if pos >= size then + if loop and loopLen > 0 then + pos = loopStart + ((pos - loopStart) % loopLen) else v.alive = false break end end - local s = s8_lerp(v.pcm, v.pos, v.size) * env - outL[i] = outL[i] + s * v.volL * 0.35 - outR[i] = outR[i] + s * v.volR * 0.35 - v.pos = v.pos + v.step + local s = s8_lerp(pcm, pos, size, loop, loopStart) * env + outL[i] = outL[i] + s * volL + outR[i] = outR[i] + s * volR + pos = pos + step end + v.pos = pos elseif v.kind == "cgb_pulse" then local duty = DUTY[v.duty] or DUTY[2] local thresh, hi, lo = duty[1], duty[2], duty[3] diff --git a/src/core/game3/m4a_player.lua b/src/core/game3/m4a_player.lua index 444b4919..5353310c 100644 --- a/src/core/game3/m4a_player.lua +++ b/src/core/game3/m4a_player.lua @@ -104,23 +104,60 @@ local function resolve_tone(pack, vgId, voiceId, midiKey, depth) return resolve_tone(pack, tone.subVgId, idx, midiKey, depth + 1) end if rhy then - return resolve_tone(pack, tone.subVgId, midiKey, midiKey, depth + 1) + local sub = resolve_tone(pack, tone.subVgId, midiKey, midiKey, depth + 1) + if sub then + sub = { + type = sub.type, + key = sub.key, + length = sub.length, + pan = sub.pan, + sampleId = sub.sampleId, + attack = sub.attack, + decay = sub.decay, + sustain = sub.sustain, + release = sub.release, + wavParam = sub.wavParam, + wave = sub.wave, + isRhy = true, + } + end + return sub end return tone end -local function make_voice_from_tone(pack, tone, key, volL, volR, fine) +local function make_voice_from_tone(pack, tone, key, volL, volR, fine, tr) if not tone then return nil end fine = tonumber(fine) or 0 local typ = tone.type or 0 local kind = typ % 8 + local noteKey = key + if tone.isRhy and tone.key then + local keyM = 0 + if tr then + keyM = Seq.trackPitch(tr) + end + noteKey = tone.key + keyM + end + + -- Rhythm pan override (if pan has bit 7 set) + if tone.isRhy and tone.pan and tone.pan >= 0x80 and tr then + local rpan = (tone.pan - 0xC0) * 2 -- -64..+62 + local totalPan = ((tr.pan or 0x40) - 0x40) + rpan + if totalPan < -64 then totalPan = -64 elseif totalPan > 63 then totalPan = 63 end + local p = totalPan / 64 + local vol = (tr.volume or 100) / 127 * ((tr.vel or 100) / 127) + volL = vol * (0.5 - p * 0.5) + volR = vol * (0.5 + p * 0.5) + end + if kind == 0 and tone.sampleId then local meta = pack.samples[tone.sampleId] or pack.samples[tostring(tone.sampleId)] local pcm = Sample.loadPcm(pack.samplesBin, meta) if not pcm or not meta then return nil end local fixed = math.floor((tone.type or 0) / 8) % 2 == 1 -- pret MidiKeyToFreq(wav, noteKey+keyM, fine) — returns playback Hz. - local rate = fixed and Mix.waveRate(meta.freq) or Mix.midiKeyToFreq(meta.freq, key, fine) + local rate = fixed and Mix.waveRate(meta.freq) or Mix.midiKeyToFreq(meta.freq, noteKey, fine) if rate < 100 then rate = Mix.waveRate(meta.freq) end local loop = (meta.loopStart or 0) > 0 and (meta.loopStart or 0) < (meta.size or 0) local v = Mix.newDsVoice(pcm, meta, { @@ -141,7 +178,7 @@ local function make_voice_from_tone(pack, tone, key, volL, volR, fine) local wp = tone.wavParam or 0 if wp <= 3 then duty = wp end return Mix.newCgbPulse({ - key = key, + key = noteKey, fine = fine, duty = duty, cgbChan = kind, @@ -155,7 +192,7 @@ local function make_voice_from_tone(pack, tone, key, volL, volR, fine) for i = 1, 32 do wave[i] = (i % 16) end end return Mix.newCgbWave({ - key = key, + key = noteKey, fine = fine, wave = wave, volL = volL, volR = volR, @@ -163,8 +200,8 @@ local function make_voice_from_tone(pack, tone, key, volL, volR, fine) }) elseif kind == 4 then return Mix.newCgbNoise({ - key = key, - period = Mix.cgbNoisePeriod(key), + key = noteKey, + period = Mix.cgbNoisePeriod(noteKey), volL = volL, volR = volR, tone = tone, }) @@ -220,7 +257,7 @@ function Player.start(pack, cache, slot, songId, opts) slot.seq = Seq.newPlayer(song, { voiceResolver = function(voiceId, key, vel, tr, volL, volR, fine) local tone = resolve_tone(pack, vgId, voiceId, key) - return make_voice_from_tone(pack, tone, key, volL, volR, fine) + return make_voice_from_tone(pack, tone, key, volL, volR, fine, tr) end, }) return true diff --git a/src/core/game3/m4a_seq.lua b/src/core/game3/m4a_seq.lua index 9fd62b1e..c3b7d3db 100644 --- a/src/core/game3/m4a_seq.lua +++ b/src/core/game3/m4a_seq.lua @@ -108,6 +108,13 @@ local function new_track(data) bendRange = 2, -- pret MPlayTrack init tune = 0, -- signed, C_V center = 0 keyShift = 0, + lfoSpeed = 22, -- pret default + lfoDelay = 0, + lfoDelayC = 0, + lfoSpeedC = 0, + mod = 0, + modT = 0, + modM = 0, callStack = {}, -- pret: commands >= 0xBD update runningStatus; bytes < 0x80 reuse it. runningStatus = nil, @@ -120,9 +127,29 @@ function Seq.trackPitch(tr) local tune = tr.tune or 0 local keyShift = tr.keyShift or 0 local x = (tune + bend) * 4 + (keyShift * 256) + if (tr.modT or 0) == 0 and (tr.mod or 0) > 0 then + x = x + 16 * (tr.modM or 0) + end return math.floor(x / 256), (x % 256) end +local function calc_track_vol(tr, vel) + vel = tonumber(vel) or tr.vel or 100 + local vol = (tr.volume or 100) / 127 * (vel / 127) + if (tr.modT or 0) == 1 and (tr.mod or 0) > 0 then + local modVol = math.max(0, math.min(255, (tr.modM or 0) + 128)) / 128 + vol = vol * modVol + end + local pan = ((tr.pan or 0x40) - 0x40) / 64 + if (tr.modT or 0) == 2 and (tr.mod or 0) > 0 then + pan = pan + (tr.modM or 0) / 128 + end + if pan < -1 then pan = -1 elseif pan > 1 then pan = 1 end + local volL = vol * (0.5 - pan * 0.5) + local volR = vol * (0.5 + pan * 0.5) + return volL, volR +end + function Seq.newPlayer(song, opts) opts = opts or {} local tracks = {} @@ -164,7 +191,7 @@ end local MAX_DS = 12 ---- pret MPlayMain: after BEND/VOL/PAN, TrkVolPitSet + rewrite active channel freqs. +--- pret MPlayMain: after BEND/VOL/PAN/MOD, TrkVolPitSet + rewrite active channel freqs. local function refresh_track_voices(player, tr) if not tr then return end local keyM, fine = Seq.trackPitch(tr) @@ -172,10 +199,9 @@ local function refresh_track_voices(player, tr) if v.track == tr and v.alive ~= false then if tr._volDirty then local nvel = v.noteVel or tr.vel or 127 - local vol = (tr.volume or 100) / 127 * (nvel / 127) - local pan = ((tr.pan or 0x40) - 0x40) / 64 - v.volL = vol * (0.5 - pan * 0.5) - v.volR = vol * (0.5 + pan * 0.5) + local volL, volR = calc_track_vol(tr, nvel) + v.volL = volL + v.volR = volR end if tr._pitchDirty and not v.fixedFreq then local noteKey = v.noteKey or 60 @@ -211,16 +237,20 @@ end local function start_note(player, tr, key, vel, gate) vel = vel or tr.vel or 100 - local vol = (tr.volume or 100) / 127 * (vel / 127) - local pan = ((tr.pan or 0x40) - 0x40) / 64 - local volL = vol * (0.5 - pan * 0.5) - local volR = vol * (0.5 + pan * 0.5) + local volL, volR = calc_track_vol(tr, vel) if not player.voiceResolver then return end local rawKey = tonumber(key) or 60 local keyM, fine = Seq.trackPitch(tr) local absKey = rawKey + keyM if absKey < 0 then absKey = 0 end if absKey > 178 then absKey = 178 end + + -- Reset LFO delay on new note per pret ply_note + if (tr.lfoDelay or 0) > 0 then + tr.lfoDelayC = tr.lfoDelay + tr.modM = 0 + end + -- Resolver gets absKey for initial freq; we store raw note key for live BEND. local voice = player.voiceResolver(tr.voice, absKey, vel, tr, volL, volR, fine) if voice then @@ -328,6 +358,28 @@ local function exec_cmd(player, tr, cmd) elseif cmd == 0xC1 then tr.bendRange = track_read(tr) or 2 tr._pitchDirty = true + elseif cmd == 0xC2 then + tr.lfoSpeed = track_read(tr) or 22 + if tr.lfoSpeed == 0 and (tr.modM or 0) ~= 0 then + tr.modM = 0 + if (tr.modT or 0) == 0 then tr._pitchDirty = true else tr._volDirty = true end + end + elseif cmd == 0xC3 then + tr.lfoDelay = track_read(tr) or 0 + tr.lfoDelayC = tr.lfoDelay + elseif cmd == 0xC4 then + tr.mod = track_read(tr) or 0 + if tr.mod == 0 and (tr.modM or 0) ~= 0 then + tr.modM = 0 + if (tr.modT or 0) == 0 then tr._pitchDirty = true else tr._volDirty = true end + end + elseif cmd == 0xC5 then + local oldT = tr.modT or 0 + tr.modT = track_read(tr) or 0 + if oldT ~= tr.modT then + tr._pitchDirty = true + tr._volDirty = true + end elseif cmd == 0xC8 then -- TUNE: signed around 0x40 (C_V) tr.tune = (track_read(tr) or 0x40) - 0x40 @@ -362,11 +414,10 @@ local function exec_cmd(player, tr, cmd) ply_note(player, tr, cmd) elseif cmd >= 0x80 and cmd <= 0xB0 then tr.wait = clock_at(cmd - 0x80) - elseif cmd == 0xBA or cmd == 0xC2 or cmd == 0xC3 or cmd == 0xC4 - or cmd == 0xC5 or cmd == 0xC6 or cmd == 0xC7 + elseif cmd == 0xBA or cmd == 0xC6 or cmd == 0xC7 or cmd == 0xC9 or cmd == 0xCA or cmd == 0xCB or cmd == 0xCC or cmd == 0xB9 then - -- PRIO/LFOS/LFODL/MOD/MODT/… (1 arg); runningStatus already set if >= 0xBD + -- PRIO/… (1 arg); runningStatus already set if >= 0xBD track_read(tr) end end @@ -424,9 +475,46 @@ local function seq_tick(player) end Mix.tickEnvelope(v) end + + -- Tick LFO modulation per active track + for _, tr in ipairs(player.tracks) do + if not tr.done and (tr.lfoSpeed or 0) > 0 and (tr.mod or 0) > 0 then + if (tr.lfoDelayC or 0) > 0 then + tr.lfoDelayC = tr.lfoDelayC - 1 + else + tr.lfoSpeedC = ((tr.lfoSpeedC or 0) + tr.lfoSpeed) % 256 + local phase = tr.lfoSpeedC + local tri + if phase < 64 then + tri = phase + elseif phase < 192 then + tri = 128 - phase + else + tri = phase - 256 + end + local newModM = math.floor((tr.mod * tri) / 64) + if newModM ~= (tr.modM or 0) then + tr.modM = newModM + if (tr.modT or 0) == 0 then + tr._pitchDirty = true + else + tr._volDirty = true + end + end + end + end + end + for _, tr in ipairs(player.tracks) do tick_track(player, tr) end + + for _, tr in ipairs(player.tracks) do + if tr._pitchDirty or tr._volDirty then + refresh_track_voices(player, tr) + end + end + -- New notes get one envelope step so attack=instant voices are audible this frame. for _, v in ipairs(player.voices) do if v.adsr and v.envPhase == "attack" and (v.envVol or 0) == 0 then diff --git a/src/core/game3/palette.lua b/src/core/game3/palette.lua index 30104895..f8a86b57 100644 --- a/src/core/game3/palette.lua +++ b/src/core/game3/palette.lua @@ -21,8 +21,8 @@ function Palette.load(blob) return NativePack.palsToRgb8(bgr), bgr end ---- Stable hash of BGR555 pals for RGBA disk cache keys. -function Palette.hash(bgrPals) +--- Stable hash of BGR555 pals (+ optional extraBlob) for RGBA disk cache keys. +function Palette.hash(bgrPals, extraBlob) local parts = {} for p = 0, Palette.NUM_PALS_TOTAL - 1 do local colors = bgrPals[p] or bgrPals[0] or {} @@ -30,6 +30,9 @@ function Palette.hash(bgrPals) parts[#parts + 1] = string.format("%04x", (colors[c] or 0) % 65536) end end + if extraBlob and type(extraBlob) == "string" then + parts[#parts + 1] = extraBlob + end -- FNV-1a 32-bit over hex stream (stable, no bit lib). local h = 2166136261 local s = table.concat(parts) diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index f8cf6a9a..f780693f 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -445,6 +445,13 @@ local function finishStep(game) return end + -- Evaluate Overworld Step Events (Happiness, VS Seeker, Poison, Egg/Daycare, Repel) + local StepEvents = package.loaded["src.core.game3.step_events"] + or require("src.core.game3.step_events") + if StepEvents and StepEvents.onStepTaken then + StepEvents.onStepTaken(session, game) + end + -- Land-on-warp via owned warp table (mapDef.warps). Collision.tryWarpAt(game, Player.cellX, Player.cellY, Player.facing) @@ -570,6 +577,8 @@ function Player.update(game, input) if Warp and Warp.isBusy and Warp.isBusy() then return end local Runtime = package.loaded["src.core.game3.runtime"] if Runtime and Runtime.uiBusy and Runtime.uiBusy() then return end + local StepEvents = package.loaded["src.core.game3.step_events"] + if StepEvents and StepEvents.busy and StepEvents.busy() then return end local Space = package.loaded["src.core.game3.scripting.space"] if Space and Space.vm and Space.vm.isRunning and Space.vm:isRunning() then return diff --git a/src/core/game3/pokecenter_heal.lua b/src/core/game3/pokecenter_heal.lua index 2d8e4979..c2c8b50e 100644 --- a/src/core/game3/pokecenter_heal.lua +++ b/src/core/game3/pokecenter_heal.lua @@ -180,7 +180,12 @@ end local function party_count() local Runtime = package.loaded["src.core.game3.runtime"] - local session = Runtime and Runtime.getSession and Runtime.getSession() + local session = (Runtime and Runtime.getSession and Runtime.getSession()) + or (Runtime and Runtime.session) + local Field = package.loaded["src.core.game3.field"] + if not session and Field and Field._session then + session = Field._session + end local Party = package.loaded["src.core.game3.party"] or require("src.core.game3.party") if session and session.party and Party.size then diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index a1c40631..3420e94f 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -753,6 +753,11 @@ function Pokemon.swapMoves(mon, slotA, slotB) return true end +function Pokemon.isEgg(mon) + if not mon then return false end + return (mon.isEgg == true) or (mon.egg == true) or (mon.species == 412) +end + local function read_rgba(species) local cache = resolve_cache(Pokemon._cache) local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon" diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index 5ee33261..fb7511a1 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -30,35 +30,53 @@ function Runtime.getSession() end function Runtime.pumpRtc(game, dt) - if not game or not game.save then return end - local save = game.save + local session = Runtime.session or (game and game.session) + local save = game and game.save + if not session and not save then return end dt = tonumber(dt) or (1 / 60) -- Standalone FRLG playtime accumulator (no hardware RTC, no Gen2 Clock). - local session = Runtime.session - local pt = (session and session.playtime) or save.playTime or save.playtime + local pt = (session and (session.playtime or session.playTime)) + or (save and (save.playTime or save.playtime)) if type(pt) ~= "table" then - pt = { hours = 0, minutes = 0, seconds = 0 } - if session then session.playtime = pt end - save.playTime = pt + pt = { hours = 0, minutes = 0, seconds = 0, vblanks = 0 } end + pt.hours = tonumber(pt.hours) or 0 + pt.minutes = tonumber(pt.minutes) or 0 + pt.seconds = tonumber(pt.seconds) or 0 + pt.vblanks = tonumber(pt.vblanks) or 0 + Runtime._playTimeAcc = (Runtime._playTimeAcc or 0) + dt while Runtime._playTimeAcc >= 1 do Runtime._playTimeAcc = Runtime._playTimeAcc - 1 - local sec = (tonumber(pt.seconds) or 0) + 1 - if sec >= 60 then - sec = 0 - local min = (tonumber(pt.minutes) or 0) + 1 - if min >= 60 then - min = 0 - pt.hours = (tonumber(pt.hours) or 0) + 1 + pt.seconds = pt.seconds + 1 + if pt.seconds >= 60 then + pt.seconds = 0 + pt.minutes = pt.minutes + 1 + if pt.minutes >= 60 then + pt.minutes = 0 + pt.hours = math.min(999, pt.hours + 1) end - pt.minutes = min end - pt.seconds = sec end - if session then session.playtime = pt end - save.playTime = pt + + if session then + session.playtime = pt + session.playTime = pt + session.playTimeHours = pt.hours + session.playTimeMinutes = pt.minutes + session.playTimeSeconds = pt.seconds + session.hours = pt.hours + session.minutes = pt.minutes + session.seconds = pt.seconds + end + if save then + save.playTime = pt + save.playtime = pt + save.playTimeHours = pt.hours + save.playTimeMinutes = pt.minutes + save.playTimeSeconds = pt.seconds + end end local function mark_host_game3(game, on) diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index b4e580a9..1b01fd9a 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -69,6 +69,15 @@ function Adapters.stub(opts) a.askYesNo = opts.askYesNo or function(cb) if cb then cb(true) end end a.fadeScreen = opts.fadeScreen or function(_mode, _speed, done) if done then done() end end a.openNaming = opts.openNaming or function(_opts, done) if done then done("RED") end end + a.hallOfFame = opts.hallOfFame or function(done) + local HallOfFame = require("src.ui.game3.hall_of_fame") + HallOfFame.start({ + session = opts.session or (opts.game and opts.game.session), + onDone = function() + if done then done() end + end, + }) + end a.bufferName = opts.bufferName or function(op, src) if op == "bufferspeciesname" then local ok, Pokemon = pcall(require, "src.core.game3.pokemon") @@ -1165,22 +1174,29 @@ function Adapters.host(mod, game, world) end, showTownMap = function(done) local Runtime = package.loaded["src.core.game3.runtime"] - if Runtime and Runtime.isActive and Runtime.isActive() then - local RegionMap = require("src.ui.game3.region_map") - a.log("[game3] showTownMap via RegionMap") - RegionMap.show({ - session = Runtime.getSession(), - onClose = function() - if done then done() end - tick_vm() - end, - }) - return - end - local TownMap = require("src.core.game3.town_map_stub") - TownMap.open(mod, resolveGame(), resolveWorld()) - if done then done() end - tick_vm() + local session = (Runtime and Runtime.getSession and Runtime.getSession()) or (resolveGame() and resolveGame().session) + local RegionMap = require("src.ui.game3.region_map") + a.log("[game3] showTownMap via RegionMap") + RegionMap.show({ + session = session, + onClose = function() + if done then done() end + tick_vm() + end, + }) + end, + hallOfFame = function(done) + local Runtime = package.loaded["src.core.game3.runtime"] + local session = (Runtime and Runtime.getSession and Runtime.getSession()) or (resolveGame() and resolveGame().session) + local HallOfFame = require("src.ui.game3.hall_of_fame") + a.log("[game3] hallOfFame induction started") + HallOfFame.start({ + session = session, + onDone = function() + if done then done() end + tick_vm() + end, + }) end, startTrainerBattle = function(foe, done, battleOpts) local BattleBridge = require("src.core.game3.battle_bridge") diff --git a/src/core/game3/scripting/trainers.lua b/src/core/game3/scripting/trainers.lua index a2b4a2a3..2b0a2c3c 100644 --- a/src/core/game3/scripting/trainers.lua +++ b/src/core/game3/scripting/trainers.lua @@ -90,6 +90,15 @@ function Trainers.get(trainerId) if row then local classNames = pack and pack.classNames local class = tonumber(row.class) or 0 + local fb = FALLBACK_TRAINERS[trainerId] + local dlgs = row.dialogs or {} + if (not dlgs.defeat or dlgs.defeat == "") and fb and fb.dialogs and fb.dialogs.defeat then + dlgs = { + intro = dlgs.intro or fb.dialogs.intro, + defeat = dlgs.defeat or fb.dialogs.defeat, + victory = dlgs.victory or fb.dialogs.victory, + } + end return { id = trainerId, class = class, @@ -106,7 +115,7 @@ function Trainers.get(trainerId) ai = decompose_ai_flags(row.aiFlags), items = row.items or { 0, 0, 0, 0 }, party = row.party or {}, - dialogs = row.dialogs or {}, + dialogs = dlgs, scriptKey = row.scriptKey, introTextKey = row.introTextKey, defeatTextKey = row.defeatTextKey, diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua new file mode 100644 index 00000000..bc0f2494 --- /dev/null +++ b/src/core/game3/step_events.lua @@ -0,0 +1,287 @@ +-- Game3 Overworld Step Events Engine (pret field_control_avatar.c / wild_encounter.c). +-- Features: +-- 1. Lockstep Event Queue: Prevents simultaneous tick collisions; flushes on party white-out. +-- 2. Happiness Step Counter (128 steps): +1 friendship to all party Pokémon. +-- 3. VS Seeker Battery (100 steps): Increments on all movement states (Walk, Run, Bike, Surf). +-- 4. Overworld Poison (4 steps): 4-frame reddish screen flash, SE_FIELD_POISON, lethal faint at 0 HP. +-- 5. Egg Cycles & Daycare (256 steps): Decrements egg cycles -> EggHatchScene; +1 EXP per step in Daycare. +-- 6. Repel Counter: Decrements steps -> "Repel's effect wore off..." on expiration. + +local Pokemon = require("src.core.game3.pokemon") + +local StepEvents = {} + +StepEvents._queue = {} +StepEvents._activeEvent = nil +StepEvents._poisonFlashTimer = 0 +StepEvents._totalSteps = 0 + +local function se(id) + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio and Audio.playSe then Audio.playSe(id) end + end) +end + +function StepEvents.busy() + return StepEvents._activeEvent ~= nil or #StepEvents._queue > 0 or StepEvents._poisonFlashTimer > 0 +end + +function StepEvents.flush() + StepEvents._queue = {} + StepEvents._activeEvent = nil + StepEvents._poisonFlashTimer = 0 +end + +function StepEvents.queueEvent(event) + if type(event) == "function" then + local fn = event + event = { run = function(onDone) fn() if onDone then onDone() end end } + end + StepEvents._queue[#StepEvents._queue + 1] = event +end + +local push_event = StepEvents.queueEvent + +local function party_is_wiped(party) + if not party or #party == 0 then return false end + local hasAlive = false + for _, mon in ipairs(party) do + local isEgg = mon.isEgg or (type(mon.egg) == "boolean" and mon.egg) + local hp = tonumber(mon.hp) or 0 + if not isEgg and hp > 0 then + hasAlive = true + break + end + end + return not hasAlive +end + +local function trigger_white_out(session, game) + StepEvents.flush() + if session and session.onWhiteout then + session.onWhiteout() + return + end + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Fade = require("src.ui.game3.fade") + + -- 1. Faint message + local Hud = require("src.ui.game3.hud") + local playerName = (session and (session.name or session.playerName)) or "PLAYER" + local msg = playerName .. " is out of usable\nPOKéMON!\n\n" .. playerName .. " whited out!" + + Hud.showDialogue(msg, { + onDone = function() + -- 2. Fade to black and warp to last heal location (or Pallet Town player house) + Fade.begin(Fade.MODE.TO_BLACK, 0.5, function() + local healMap = (session and session.healMap) or "FR_PALLET_TOWN_PLAYERS_HOUSE_2F" + local healX = (session and session.healX) or 6 + local healY = (session and session.healY) or 6 + local healFacing = (session and session.healFacing) or "down" + + -- Heal all party Pokémon + if session and session.party then + for _, mon in ipairs(session.party) do + local maxHp = tonumber(mon.maxHp or mon.maxhp) or 1 + mon.hp = maxHp + mon.status = nil + mon.statusNum = 0 + end + end + + Map.load(Runtime._mod, game, healMap, { + x = healX, + y = healY, + facing = healFacing, + }) + Fade.begin(Fade.MODE.FROM_BLACK, 0.5) + end) + end + }) +end + +--- Evaluate step counters upon completing a grid step (Walk, Run, Bike, Surf). +function StepEvents.onStepTaken(session, game) + if not session then return end + session.vars = session.vars or {} + local party = session.party or {} + StepEvents._totalSteps = StepEvents._totalSteps + 1 + + -- 1. Happiness Counter (VAR_HAPPINESS_STEP_COUNTER % 128) + local hapSteps = (tonumber(session.vars[0x403F] or session.happinessSteps) or 0) + 1 + if hapSteps >= 128 then + hapSteps = 0 + for _, mon in ipairs(party) do + if not (mon.isEgg or (type(mon.egg) == "boolean" and mon.egg)) then + local curHap = tonumber(mon.friendship or mon.happiness) or 70 + if curHap < 255 then + mon.friendship = math.min(255, curHap + 1) + mon.happiness = mon.friendship + end + end + end + end + session.vars[0x403F] = hapSteps + session.happinessSteps = hapSteps + + -- 2. VS Seeker Battery Counter (up to 100 steps on any movement mode) + local vsCharge = tonumber(session.vsSeekerCharge or session.vars[0x4044]) or 0 + if vsCharge < 100 then + vsCharge = math.min(100, vsCharge + 1) + session.vsSeekerCharge = vsCharge + session.vars[0x4044] = vsCharge + end + + -- 3. Overworld Poison Counter (every 4 steps, pret field_poison.c) + local psnSteps = (tonumber(session.vars[0x4040] or session.poisonSteps) or 0) + 1 + if psnSteps >= 4 then + psnSteps = 0 + local anyPoisonDamage = false + local faintedMons = {} + + for slotIdx, mon in ipairs(party) do + local isEgg = mon.isEgg or (type(mon.egg) == "boolean" and mon.egg) + local st = tostring(mon.status or ""):upper() + local isPsn = (st == "PSN" or st == "POISON" or st == "TOXIC" or (tonumber(mon.statusNum) or 0) == 8) + local hp = tonumber(mon.hp) or 0 + + if not isEgg and isPsn and hp > 0 then + anyPoisonDamage = true + mon.hp = math.max(0, hp - 1) + if mon.hp == 0 then + mon.status = nil + mon.statusNum = 0 + faintedMons[#faintedMons + 1] = { + slot = slotIdx, + mon = mon, + name = Pokemon.displayMonName(mon), + } + end + end + end + + if anyPoisonDamage then + -- Trigger 4-frame reddish screen flash and poison SE + StepEvents._poisonFlashTimer = 4 / 60 + se(35) -- SE_FIELD_POISON + + for _, fainted in ipairs(faintedMons) do + push_event({ + type = "poison_faint", + name = fainted.name, + mon = fainted.mon, + run = function(onDone) + -- Play mon cry + pcall(function() + local Audio = require("src.core.game3.audio") + local sp = Pokemon.speciesOf(fainted.mon) + if Audio and Audio.playCry and sp then Audio.playCry(sp) end + end) + + local Hud = require("src.ui.game3.hud") + Hud.showDialogue(fainted.name .. " fainted...", { + onDone = function() + if party_is_wiped(party) then + trigger_white_out(session, game) + else + onDone() + end + end + }) + end, + }) + end + end + end + session.vars[0x4040] = psnSteps + session.poisonSteps = psnSteps + + -- 4. Daycare EXP & Egg Hatching Cycles (every 256 steps) + local eggSteps = (tonumber(session.eggSteps) or 0) + 1 + if eggSteps >= 256 then + eggSteps = 0 + for slotIdx, mon in ipairs(party) do + local isEgg = mon.isEgg or (type(mon.egg) == "boolean" and mon.egg) + if isEgg then + local cycles = tonumber(mon.friendship or mon.eggCycles or mon.cycles) or 20 + cycles = math.max(0, cycles - 1) + mon.friendship = cycles + mon.eggCycles = cycles + if cycles == 0 then + push_event({ + type = "egg_hatch", + mon = mon, + slot = slotIdx, + run = function(onDone) + local EvolutionScene = require("src.ui.game3.evolution_scene") + local Audio = require("src.core.game3.audio") + mon.isEgg = false + mon.egg = false + mon.level = 5 + Pokemon.applyStats(mon) + EvolutionScene.start(mon, mon.species or mon.speciesId, { + session = session, + isEggHatch = true, + savedSong = Audio._mapSong, + onDone = onDone, + }) + end, + }) + end + end + end + end + session.eggSteps = eggSteps + + -- 5. Repel Step Counter (VAR_REPEL_STEP_COUNT) + local repelSteps = tonumber(session.repelSteps or session.vars[0x4021]) or 0 + if repelSteps > 0 then + repelSteps = repelSteps - 1 + session.repelSteps = repelSteps + session.vars[0x4021] = repelSteps + + if repelSteps == 0 then + push_event({ + type = "repel_wore_off", + run = function(onDone) + se(67) -- SE_REPEL + local Hud = require("src.ui.game3.hud") + Hud.showDialogue("Repel's effect wore off...", { + onDone = onDone, + }) + end, + }) + end + end +end + +--- Pump the sequential lockstep queue. +function StepEvents.update(dt, game) + if StepEvents._poisonFlashTimer > 0 then + StepEvents._poisonFlashTimer = math.max(0, StepEvents._poisonFlashTimer - (dt or 1 / 60)) + end + + if StepEvents._activeEvent then return end + if #StepEvents._queue == 0 then return end + + local ev = table.remove(StepEvents._queue, 1) + StepEvents._activeEvent = ev + ev.run(function() + StepEvents._activeEvent = nil + end) +end + +--- Render screen flash if poison triggered. +function StepEvents.draw() + if StepEvents._poisonFlashTimer > 0 then + love.graphics.setColor(0.85, 0.15, 0.15, 0.45) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + end +end + +StepEvents.onStep = StepEvents.onStepTaken + +return StepEvents diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua index 504fb29d..6b579d00 100644 --- a/src/core/game3/tileset_native.lua +++ b/src/core/game3/tileset_native.lua @@ -93,7 +93,7 @@ local function load_pair(cache, pair) local rgb, bgr = Palette.load(palBlob) if not rgb then return nil, bgr end - local hash = Palette.hash(bgr) + local hash = Palette.hash(bgr, idxBlob) local layered = cache:exists(NATIVE .. "/" .. pair .. "/mids_over.idx") local tag = layered and "u" or "flat" local image, imageData, err = bake_or_load(cache, pair, idxTbl, rgb, hash, tag, false) @@ -128,7 +128,8 @@ local function load_pair(cache, pair) local overBlob = cache:read(NATIVE .. "/" .. pair .. "/mids_over.idx") local overTbl = overBlob and NativePack.decodeIdx(overBlob) if overTbl then - local oImg, oData = bake_or_load(cache, pair, overTbl, rgb, hash, "o", true) + local overHash = Palette.hash(bgr, overBlob) + local oImg, oData = bake_or_load(cache, pair, overTbl, rgb, overHash, "o", true) if oImg then ts.layered = true ts.overImage = oImg diff --git a/src/core/game3/vs_seeker.lua b/src/core/game3/vs_seeker.lua new file mode 100644 index 00000000..f87b4654 --- /dev/null +++ b/src/core/game3/vs_seeker.lua @@ -0,0 +1,110 @@ +-- VS Seeker Overworld Radar & Battery Engine (pret vs_seeker.c). +-- Features: +-- 1. Battery charge management: 100 steps on any locomotion mode (Walk, Run, Bike, Surf). +-- 2. Hard indoor/cave validation rejection: Preserves 100 charge. +-- 3. Outdoor radar ping: Emits signal, drains battery to 0 (even on "no response"). +-- 4. Trainer detection (8-tile radius) with animated '!!' alert emoticons and rematch party progression. + +local VsSeeker = {} + +local function se(id) + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio and Audio.playSe then Audio.playSe(id) end + end) +end + +function VsSeeker.getBattery(session) + if not session then return 0 end + return tonumber(session.vsSeekerCharge or (session.vars and session.vars[0x4044])) or 0 +end + +function VsSeeker.setBattery(session, charge) + if not session then return end + charge = math.min(100, math.max(0, math.floor(tonumber(charge) or 0))) + session.vsSeekerCharge = charge + if session.vars then session.vars[0x4044] = charge end +end + +local function is_valid_map(mapId) + mapId = tostring(mapId or ""):upper() + -- Caves, gyms, buildings, and indoor rooms reject VS Seeker + if mapId:find("_HOUSE") or mapId:find("_POKECENTER") or mapId:find("_MART") + or mapId:find("_GYM") or mapId:find("_CAVE") or mapId:find("_TUNNEL") + or mapId:find("MT_MOON") or mapId:find("VIRIDIAN_FOREST") + or mapId:find("ROCK_TUNNEL") or mapId:find("SEAFOAM") + or mapId:find("VICTORY_ROAD") or mapId:find("POKEMON_MANSION") + or mapId:find("POKEMON_TOWER") or mapId:find("POWER_PLANT") + or mapId:find("SAFARI_ZONE") or mapId:find("_1F") or mapId:find("_2F") or mapId:find("_3F") + or mapId:find("_B1F") or mapId:find("_B2F") then + return false + end + return true +end + +--- Use the VS Seeker in the overworld. +function VsSeeker.use(session, game, onDone) + local Hud = require("src.ui.game3.hud") + local currentMap = (session and session.map) or "" + + -- 1. Location check + if not is_valid_map(currentMap) then + Hud.showDialogue("This can't be used here.", { onDone = onDone }) + return false + end + + -- 2. Battery check + local battery = VsSeeker.getBattery(session) + if battery < 100 then + local needed = 100 - battery + local msg = string.format("The battery has run down!\nNo. of steps to charge: %d", needed) + Hud.showDialogue(msg, { onDone = onDone }) + return false + end + + -- 3. Battery drain on valid search signal + VsSeeker.setBattery(session, 0) + se(43) -- SE_PIN + + -- 4. Scan nearby NPC trainers in 8-tile radius + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local px, py = Player.cellX, Player.cellY + local rematchTrainers = {} + + for _, obj in ipairs(Objects.all and Objects.all() or {}) do + local def = obj.def or {} + local isTrainer = def.trainerType ~= nil and def.trainerType > 0 + if isTrainer then + local dx = math.abs((obj.x or 0) - px) + local dy = math.abs((obj.y or 0) - py) + if dx <= 8 and dy <= 8 then + rematchTrainers[#rematchTrainers + 1] = obj + end + end + end + + if #rematchTrainers > 0 then + -- Trigger rematch alert emoticons + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio and Audio.playSong then Audio.playSong(299, { loop = false }) end -- MUS_VS_SEEKER_SEARCH + end) + + local FieldEffects = require("src.core.game3.field_effects") + for _, tr in ipairs(rematchTrainers) do + if FieldEffects and FieldEffects.spawnEmoticon then + FieldEffects.spawnEmoticon(tr, 1) -- Double Exclamation / Alert + end + tr._rematchReady = true + end + + if onDone then onDone(true, #rematchTrainers) end + return true + else + Hud.showDialogue("There is no response...", { onDone = onDone }) + return false + end +end + +return VsSeeker diff --git a/src/import/gba/battle_anim_extract.lua b/src/import/gba/battle_anim_extract.lua index 52df229d..67191f23 100644 --- a/src/import/gba/battle_anim_extract.lua +++ b/src/import/gba/battle_anim_extract.lua @@ -32,7 +32,7 @@ local OP_FIXED = { [0x14]=2,[0x15]=1,[0x16]=1,[0x17]=1,[0x18]=2, -- fadetobg/restorebg/waitbgfadeout/waitbgfadein/changebg [0x19]=4, -- playsewithpan [0x1A]=2, -- setpan - [0x1B]=8, -- panse + [0x1B]=7, -- panse [0x1C]=6, -- loopsewithpan [0x1D]=5, -- waitplaysewithpan [0x1E]=3, -- setbldcnt @@ -41,7 +41,7 @@ local OP_FIXED = { [0x22]=2,[0x23]=2, -- monbg_static / clearmonbg_static [0x24]=5, -- jumpifcontest [0x25]=4, -- fadetobgfromset - [0x26]=8,[0x27]=8, -- panse_adjustnone / panse_adjustall + [0x26]=7,[0x27]=7, -- 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 @@ -239,6 +239,24 @@ local function decode_script(rom, startOff, visited, labels, tag_dims) ops[#ops + 1] = { op = "setarg", argId = argId, value = val } i = i + 4 + elseif op == 0x11 then -- choosetwoturnanim + local ptr1 = rom:u32(i + 1) + local ptr2 = rom:u32(i + 5) + local off1 = rom:ptrOffset(ptr1) + local off2 = rom:ptrOffset(ptr2) + ops[#ops + 1] = { op = "choosetwoturnanim", label1 = tostring(off1), label2 = tostring(off2) } + i = i + 9 + if off1 and not visited[off1] then decode_script(rom, off1, visited, labels, tag_dims) end + if off2 and not visited[off2] then decode_script(rom, off2, visited, labels, tag_dims) end + + elseif op == 0x12 then -- jumpifmoveturn + local toCheck = rom:get(i + 1) + local ptr = rom:u32(i + 2) + local off = rom:ptrOffset(ptr) + ops[#ops + 1] = { op = "jumpifmoveturn", turn = toCheck, label = tostring(off) } + i = i + 6 + if off and not visited[off] then decode_script(rom, off, visited, labels, tag_dims) end + elseif op == 0x13 then -- goto local target_gba = rom:u32(i + 1) local target_off = rom:ptrOffset(target_gba) @@ -249,12 +267,35 @@ local function decode_script(rom, startOff, visited, labels, tag_dims) end break + elseif op == 0x14 then -- fadetobg + local bgId = rom:get(i + 1) + ops[#ops + 1] = { op = "fadetobg", bg = bgId } + i = i + 2 + + elseif op == 0x15 then -- restorebg + ops[#ops + 1] = { op = "restorebg" } + i = i + 1 + + elseif op == 0x18 then -- changebg + local bgId = rom:get(i + 1) + ops[#ops + 1] = { op = "changebg", bg = bgId } + i = i + 2 + 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 == 0x1B then -- panse + local se = rom:u16(i + 1) + local pan1 = s8(rom:get(i + 3)) + local pan2 = s8(rom:get(i + 4)) + local step = s8(rom:get(i + 5)) + local wait = rom:get(i + 6) + ops[#ops + 1] = { op = "panse", se = se, pan = pan1, targetPan = pan2, step = step, wait = wait } + i = i + 7 + elseif op == 0x1C then -- loopsewithpan local se = rom:u16(i + 1) local pan = s8(rom:get(i + 3)) @@ -280,6 +321,33 @@ local function decode_script(rom, startOff, visited, labels, tag_dims) ops[#ops + 1] = { op = "nop" } -- treat as nop; sound handled by SE ops i = i + 6 + argc * 2 + elseif op == 0x21 then -- jumpargeq + local argId = rom:get(i + 1) + local val = s16(rom:u16(i + 2)) + local ptr = rom:u32(i + 4) + local off = rom:ptrOffset(ptr) + ops[#ops + 1] = { op = "jumpargeq", argId = argId, value = val, label = tostring(off) } + i = i + 8 + if off and not visited[off] then decode_script(rom, off, visited, labels, tag_dims) end + + elseif op == 0x25 then -- fadetobgfromset + ops[#ops + 1] = { op = "fadetobgfromset", bg1 = rom:get(i + 1), bg2 = rom:get(i + 2), bg3 = rom:get(i + 3) } + i = i + 4 + + elseif op == 0x26 or op == 0x27 then -- panse_adjustnone / panse_adjustall + local se = rom:u16(i + 1) + local pan1 = s8(rom:get(i + 3)) + local pan2 = s8(rom:get(i + 4)) + local step = s8(rom:get(i + 5)) + local wait = rom:get(i + 6) + ops[#ops + 1] = { op = "panse", se = se, pan = pan1, targetPan = pan2, step = step, wait = wait } + i = i + 7 + + elseif op == 0x28 or op == 0x29 or op == 0x2A then -- splitbgprio / splitbgprio_all / splitbgprio_foes + local b = (op == 0x28) and rom:get(i + 1) or 1 + ops[#ops + 1] = { op = "splitbgprio", battler = BATTLER_NAMES[b] or "target" } + i = i + (op == 0x29 and 1 or 2) + elseif op == 0x2B then -- invisible ops[#ops + 1] = { op = "invisible", battler = BATTLER_NAMES[rom:get(i+1)] or "attacker" } i = i + 2 diff --git a/src/import/gba/berry_pouch_extract.lua b/src/import/gba/berry_pouch_extract.lua new file mode 100644 index 00000000..129c1d8b --- /dev/null +++ b/src/import/gba/berry_pouch_extract.lua @@ -0,0 +1,211 @@ +-- Bake FRLG Berry Pouch chrome from ROM into CacheFS (data/generated/gba/items/berry_pouch/). +-- Source: pret graphics/berry_pouch/ (background.4bpp.lz, background.bin.lz, background.gbapal.lz, background_female.gbapal.lz, berry_pouch.4bpp.lz, berry_pouch.gbapal.lz). + +local Versions = require("src.import.gba.versions") +local Lz77 = require("src.import.gba.lz77") + +local BerryPouchExtract = {} + +BerryPouchExtract.CACHE_SUB = "items/berry_pouch" +BerryPouchExtract.FORMAT_VERSION = 1 + +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 + end + return "data/generated/gba" +end + +local function bgr555_to_rgb8(c) + c = (tonumber(c) or 0) % 32768 + local r5 = c % 32 + local g5 = math.floor(c / 32) % 32 + local b5 = math.floor(c / 1024) % 32 + return math.floor(r5 * 255 / 31 + 0.5), + math.floor(g5 * 255 / 31 + 0.5), + math.floor(b5 * 255 / 31 + 0.5) +end + +local function byte_len(buf) + if type(buf) ~= "table" then return 0 end + return buf._len or #buf +end + +local function decode_tile_4bpp(tileBytes, out, baseX, baseY, stride, hflip, vflip) + for row = 0, 7 do + local srcRow = vflip and (7 - row) or row + for bx = 0, 3 do + local byte = tileBytes[srcRow * 4 + bx + 1] or 0 + local p0 = byte % 16 + local p1 = math.floor(byte / 16) % 16 + local x0 = bx * 2 + local x1 = x0 + 1 + if hflip then + x0, x1 = 7 - x0, 7 - x1 + end + out[(baseY + row) * stride + (baseX + x0) + 1] = p0 + out[(baseY + row) * stride + (baseX + x1) + 1] = p1 + end + end +end + +local function load_pal_banks(bytes, count) + local banks = {} + local n = count or math.floor(byte_len(bytes) / 32) + for b = 0, n - 1 do + local colors = {} + local off = b * 32 + for c = 0, 15 do + local i = off + c * 2 + 1 + colors[c] = (bytes[i] or 0) + (bytes[i + 1] or 0) * 256 + end + banks[b] = colors + end + return banks +end + +--- Render 240x160 background from 4bpp tiles + tilemap + palette banks. +local function bake_bg_rgba(gfx, palBanks, map, W, H) + local tileCount = math.floor(byte_len(gfx) / 32) + local mapW = 32 + local indices, pals = {}, {} + for i = 1, W * H do indices[i] = 0; pals[i] = 0 end + + local tilesH = math.min(32, math.floor(H / 8)) + local tilesW = math.min(32, math.floor(W / 8)) + for ty = 0, tilesH - 1 do + for tx = 0, tilesW - 1 do + local mi = (ty * mapW + tx) * 2 + 1 + local entry = (map[mi] or 0) + (map[mi + 1] or 0) * 256 + local tileId = entry % 1024 + local hflip = math.floor(entry / 1024) % 2 == 1 + local vflip = math.floor(entry / 2048) % 2 == 1 + local palNum = math.floor(entry / 4096) % 16 + if tileId < tileCount then + local tile = {} + local base = tileId * 32 + for i = 1, 32 do tile[i] = gfx[base + i] or 0 end + local tmp = {} + for i = 1, 64 do tmp[i] = 0 end + decode_tile_4bpp(tile, tmp, 0, 0, 8, hflip, vflip) + for row = 0, 7 do + for col = 0, 7 do + local px, py = tx * 8 + col, ty * 8 + row + if px < W and py < H then + local di = py * W + px + 1 + indices[di] = tmp[row * 8 + col + 1] or 0 + pals[di] = palNum + end + end + end + end + end + end + + local chunks = {} + for i = 1, W * H do + local bank = palBanks[pals[i]] or palBanks[0] or {} + local col = bank[indices[i]] or 0 + local r, g, b = bgr555_to_rgb8(col) + chunks[i] = string.char(r, g, b, 255) + end + return table.concat(chunks) +end + +--- Render 64x64 Berry Pouch animated sprite. +local function bake_pouch_sprite_rgba(gfx, palBytes) + local W, H = 64, 64 + local banks = load_pal_banks(palBytes, 1) + local pal = banks[0] or {} + local tileCount = math.floor(byte_len(gfx) / 32) + local pixels = {} + for i = 1, W * H do pixels[i] = 0 end + + local ti = 0 + for ty = 0, 7 do + for tx = 0, 7 do + if ti < tileCount then + local tile = {} + local base = ti * 32 + for i = 1, 32 do tile[i] = gfx[base + i] or 0 end + decode_tile_4bpp(tile, pixels, tx * 8, ty * 8, W, false, false) + end + ti = ti + 1 + end + end + + local chunks = {} + for i = 1, W * H do + local idx = pixels[i] or 0 + if idx == 0 then + chunks[i] = string.char(0, 0, 0, 0) + else + local r, g, b = bgr555_to_rgb8(pal[idx] or 0) + chunks[i] = string.char(r, g, b, 255) + end + end + return table.concat(chunks) +end + +function BerryPouchExtract.run(rom, cache, opts) + opts = opts or {} + local cacheRoot = opts.cacheRoot or default_cache_root() + local root = cacheRoot .. "/" .. BerryPouchExtract.CACHE_SUB + local W = opts.width or 240 + local H = opts.height or 160 + + local function get(i) return rom:get(i) end + + local gfx = Lz77.decompress(get, Versions.BERRY_POUCH_BG_GFX) + local map = Lz77.decompress(get, Versions.BERRY_POUCH_BG_TILEMAP) + local palMale = Lz77.decompress(get, Versions.BERRY_POUCH_BG_PAL) + local palFemaleOverride = Lz77.decompress(get, Versions.BERRY_POUCH_BG_PAL_FEMALE) + + local maleBanks = load_pal_banks(palMale, 3) + local femaleBanks = load_pal_banks(palMale, 3) + local overrideBank = load_pal_banks(palFemaleOverride, 1)[0] + if overrideBank then + femaleBanks[0] = overrideBank + end + + -- 1. Backgrounds + local rgbaMale = bake_bg_rgba(gfx, maleBanks, map, W, H) + local rgbaFemale = bake_bg_rgba(gfx, femaleBanks, map, W, H) + cache:write(root .. "/bg_male.rgba", rgbaMale) + cache:write(root .. "/bg_female.rgba", rgbaFemale) + + -- 2. Berry Pouch Sprite (64x64) + local spriteGfx = Lz77.decompress(get, Versions.BERRY_POUCH_SPRITE_GFX) + local spritePal = Lz77.decompress(get, Versions.BERRY_POUCH_SPRITE_PAL) + local pouchRgba = bake_pouch_sprite_rgba(spriteGfx, spritePal) + cache:write(root .. "/pouch.rgba", pouchRgba) + + local manifest = string.format([[ +return { + format_version = %d, + width = %d, + height = %d, + pouchW = 64, + pouchH = 64, +} +]], BerryPouchExtract.FORMAT_VERSION, W, H) + cache:write(root .. "/manifest.lua", manifest) + + print(string.format("[berry_pouch_extract] Berry Pouch chrome baked (2 backgrounds, 64x64 pouch sprite) -> %s", root)) + return { + root = root, + width = W, + height = H, + } +end + +function BerryPouchExtract.ready(cache, cacheRoot) + local root = (cacheRoot or default_cache_root()) .. "/" .. BerryPouchExtract.CACHE_SUB + if cache and cache.exists then + return cache:exists(root .. "/bg_male.rgba") and cache:exists(root .. "/pouch.rgba") + end + return false +end + +return BerryPouchExtract diff --git a/src/import/gba/cli_extract.lua b/src/import/gba/cli_extract.lua index 1ee9e6d4..e72e3b60 100644 --- a/src/import/gba/cli_extract.lua +++ b/src/import/gba/cli_extract.lua @@ -227,10 +227,8 @@ 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 outRoot = outDir + local packCache = cache local rom = assert(Rom.open(imports, "firered")) print("Extracting battle anim IR →", outRoot .. "/data/generated/gba/pokemon/battle_anims") local animCache = { diff --git a/src/import/gba/extract_island1.lua b/src/import/gba/extract_island1.lua index 3257551b..743c31f2 100644 --- a/src/import/gba/extract_island1.lua +++ b/src/import/gba/extract_island1.lua @@ -426,6 +426,28 @@ function Extract.run(imports, cache, progressCb) print("[shop_chrome] warn: " .. tostring(detailS)) end end + do + local TmCaseExtract = require("src.import.gba.tm_case_extract") + local okT, detailT = pcall(TmCaseExtract.run, rom2, cache, { + cacheRoot = Extract.CACHE_ROOT, + }) + if okT and detailT then + print(string.format("[tm_case_extract] → %s", tostring(detailT.root))) + elseif not okT then + print("[tm_case_extract] warn: " .. tostring(detailT)) + end + end + do + local BerryPouchExtract = require("src.import.gba.berry_pouch_extract") + local okP, detailP = pcall(BerryPouchExtract.run, rom2, cache, { + cacheRoot = Extract.CACHE_ROOT, + }) + if okP and detailP then + print(string.format("[berry_pouch_extract] → %s", tostring(detailP.root))) + elseif not okP then + print("[berry_pouch_extract] warn: " .. tostring(detailP)) + end + end do local AnimExtract = require("src.import.gba.battle_anim_extract") local animCache = { diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index d42e0513..8bffdf83 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -796,6 +796,16 @@ function PokemonExtract.run(rom, cache, opts) TrainerCardExtract.run(rom, cache, { cacheRoot = cacheRoot }) end) + local TmCaseExtract = require("src.import.gba.tm_case_extract") + pcall(function() + TmCaseExtract.run(rom, cache, { cacheRoot = cacheRoot }) + end) + + local BerryPouchExtract = require("src.import.gba.berry_pouch_extract") + pcall(function() + BerryPouchExtract.run(rom, cache, { cacheRoot = cacheRoot }) + end) + if progress then progress("trainers", 0, 1) end local TrainerExtract = require("src.import.gba.trainer_extract") local trainers = TrainerExtract.run(rom, cache, { cacheRoot = cacheRoot }) diff --git a/src/import/gba/region_map_extract.lua b/src/import/gba/region_map_extract.lua new file mode 100644 index 00000000..9af5b07e --- /dev/null +++ b/src/import/gba/region_map_extract.lua @@ -0,0 +1,235 @@ +-- Bake Kanto Region Map background, cursor, and map section grid from ROM / pret assets. +-- Outputs to data/generated/gba/region_map/: +-- kanto_map.rgba (240x160) +-- cursor.rgba (16x16) +-- player_red.rgba (16x16), player_leaf.rgba (16x16) +-- map_sections.lua + +local RegionMapExtract = {} + +RegionMapExtract.CACHE_SUB = "region_map" +RegionMapExtract.FORMAT_VERSION = 1 + +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 + end + return "data/generated/gba" +end + +local function bgr555_to_rgb8(c) + c = (tonumber(c) or 0) % 32768 + local r5 = c % 32 + local g5 = math.floor(c / 32) % 32 + local b5 = math.floor(c / 1024) % 32 + return math.floor(r5 * 255 / 31 + 0.5), + math.floor(g5 * 255 / 31 + 0.5), + math.floor(b5 * 255 / 31 + 0.5) +end + +-- Kanto 22x15 layout from pokefirered/src/data/region_map/region_map_layout_kanto.h +RegionMapExtract.MAP_WIDTH = 22 +RegionMapExtract.MAP_HEIGHT = 15 + +RegionMapExtract.SECTION_NAMES = { + MAPSEC_PALLET_TOWN = "PALLET TOWN", + MAPSEC_VIRIDIAN_CITY = "VIRIDIAN CITY", + MAPSEC_PEWTER_CITY = "PEWTER CITY", + MAPSEC_CERULEAN_CITY = "CERULEAN CITY", + MAPSEC_LAVENDER_TOWN = "LAVENDER TOWN", + MAPSEC_VERMILION_CITY = "VERMILION CITY", + MAPSEC_CELADON_CITY = "CELADON CITY", + MAPSEC_FUCHSIA_CITY = "FUCHSIA CITY", + MAPSEC_CINNABAR_ISLAND = "CINNABAR ISLAND", + MAPSEC_INDIGO_PLATEAU = "INDIGO PLATEAU", + MAPSEC_SAFFRON_CITY = "SAFFRON CITY", + MAPSEC_ROUTE_4_POKECENTER = "ROUTE 4", + MAPSEC_ROUTE_10_POKECENTER = "ROUTE 10", + MAPSEC_ROUTE_1 = "ROUTE 1", + MAPSEC_ROUTE_2 = "ROUTE 2", + MAPSEC_ROUTE_3 = "ROUTE 3", + MAPSEC_ROUTE_4 = "ROUTE 4", + MAPSEC_ROUTE_5 = "ROUTE 5", + MAPSEC_ROUTE_6 = "ROUTE 6", + MAPSEC_ROUTE_7 = "ROUTE 7", + MAPSEC_ROUTE_8 = "ROUTE 8", + MAPSEC_ROUTE_9 = "ROUTE 9", + MAPSEC_ROUTE_10 = "ROUTE 10", + MAPSEC_ROUTE_11 = "ROUTE 11", + MAPSEC_ROUTE_12 = "ROUTE 12", + MAPSEC_ROUTE_13 = "ROUTE 13", + MAPSEC_ROUTE_14 = "ROUTE 14", + MAPSEC_ROUTE_15 = "ROUTE 15", + MAPSEC_ROUTE_16 = "ROUTE 16", + MAPSEC_ROUTE_17 = "ROUTE 17", + MAPSEC_ROUTE_18 = "ROUTE 18", + MAPSEC_ROUTE_19 = "ROUTE 19", + MAPSEC_ROUTE_20 = "ROUTE 20", + MAPSEC_ROUTE_21 = "ROUTE 21", + MAPSEC_ROUTE_22 = "ROUTE 22", + MAPSEC_ROUTE_23 = "ROUTE 23", + MAPSEC_ROUTE_24 = "ROUTE 24", + MAPSEC_ROUTE_25 = "ROUTE 25", + MAPSEC_VIRIDIAN_FOREST = "VIRIDIAN FOREST", + MAPSEC_MT_MOON = "MT. MOON", + MAPSEC_S_S_ANNE = "S.S. ANNE", + MAPSEC_UNDERGROUND_PATH = "UNDERGROUND PATH", + MAPSEC_UNDERGROUND_PATH_2 = "UNDERGROUND PATH", + MAPSEC_DIGLETTS_CAVE = "DIGLETT'S CAVE", + MAPSEC_KANTO_VICTORY_ROAD = "VICTORY ROAD", + MAPSEC_ROCKET_HIDEOUT = "ROCKET HIDEOUT", + MAPSEC_SILPH_CO = "SILPH CO.", + MAPSEC_POKEMON_MANSION = "POKéMON MANSION", + MAPSEC_KANTO_SAFARI_ZONE = "SAFARI ZONE", + MAPSEC_POKEMON_TOWER = "POKéMON TOWER", + MAPSEC_CERULEAN_CAVE = "CERULEAN CAVE", + MAPSEC_POWER_PLANT = "POWER PLANT", + MAPSEC_SEAFOAM_ISLANDS = "SEAFOAM ISLANDS", + MAPSEC_ROCK_TUNNEL = "ROCK TUNNEL", +} + +-- [y][x] 0-indexed layout (15 rows, 22 cols) +RegionMapExtract.KANTO_GRID = { + [0] = { [0]=nil }, + [1] = { [14]="MAPSEC_ROUTE_24", [15]="MAPSEC_ROUTE_25", [16]="MAPSEC_ROUTE_25" }, + [2] = { [14]="MAPSEC_ROUTE_24" }, + [3] = { [2]="MAPSEC_INDIGO_PLATEAU", [8]="MAPSEC_ROUTE_4_POKECENTER", [9]="MAPSEC_ROUTE_4", [10]="MAPSEC_ROUTE_4", [11]="MAPSEC_ROUTE_4", [12]="MAPSEC_ROUTE_4", [13]="MAPSEC_ROUTE_4", [14]="MAPSEC_CERULEAN_CITY", [15]="MAPSEC_ROUTE_9", [16]="MAPSEC_ROUTE_9", [17]="MAPSEC_ROUTE_9", [18]="MAPSEC_ROUTE_10_POKECENTER" }, + [4] = { [2]="MAPSEC_ROUTE_23", [4]="MAPSEC_PEWTER_CITY", [5]="MAPSEC_ROUTE_3", [6]="MAPSEC_ROUTE_3", [7]="MAPSEC_ROUTE_3", [8]="MAPSEC_ROUTE_3", [14]="MAPSEC_ROUTE_5", [18]="MAPSEC_ROUTE_10" }, + [5] = { [2]="MAPSEC_ROUTE_23", [4]="MAPSEC_ROUTE_2", [14]="MAPSEC_ROUTE_5", [18]="MAPSEC_ROUTE_10" }, + [6] = { [2]="MAPSEC_ROUTE_23", [4]="MAPSEC_ROUTE_2", [7]="MAPSEC_ROUTE_16", [8]="MAPSEC_ROUTE_16", [9]="MAPSEC_ROUTE_16", [10]="MAPSEC_ROUTE_16", [11]="MAPSEC_CELADON_CITY", [12]="MAPSEC_ROUTE_7", [13]="MAPSEC_ROUTE_7", [14]="MAPSEC_SAFFRON_CITY", [15]="MAPSEC_ROUTE_8", [16]="MAPSEC_ROUTE_8", [17]="MAPSEC_ROUTE_8", [18]="MAPSEC_LAVENDER_TOWN" }, + [7] = { [2]="MAPSEC_ROUTE_23", [4]="MAPSEC_ROUTE_2", [7]="MAPSEC_ROUTE_17", [14]="MAPSEC_ROUTE_6", [18]="MAPSEC_ROUTE_12" }, + [8] = { [2]="MAPSEC_ROUTE_22", [3]="MAPSEC_ROUTE_22", [4]="MAPSEC_VIRIDIAN_CITY", [7]="MAPSEC_ROUTE_17", [14]="MAPSEC_ROUTE_6", [18]="MAPSEC_ROUTE_12" }, + [9] = { [4]="MAPSEC_ROUTE_1", [7]="MAPSEC_ROUTE_17", [14]="MAPSEC_VERMILION_CITY", [15]="MAPSEC_ROUTE_11", [16]="MAPSEC_ROUTE_11", [17]="MAPSEC_ROUTE_11", [18]="MAPSEC_ROUTE_12" }, + [10] = { [4]="MAPSEC_ROUTE_1", [7]="MAPSEC_ROUTE_17", [18]="MAPSEC_ROUTE_12" }, + [11] = { [4]="MAPSEC_PALLET_TOWN", [7]="MAPSEC_ROUTE_17", [15]="MAPSEC_ROUTE_14", [16]="MAPSEC_ROUTE_13", [17]="MAPSEC_ROUTE_13", [18]="MAPSEC_ROUTE_12" }, + [12] = { [4]="MAPSEC_ROUTE_21", [7]="MAPSEC_ROUTE_18", [8]="MAPSEC_ROUTE_18", [9]="MAPSEC_ROUTE_18", [10]="MAPSEC_ROUTE_18", [11]="MAPSEC_ROUTE_18", [12]="MAPSEC_FUCHSIA_CITY", [13]="MAPSEC_ROUTE_15", [14]="MAPSEC_ROUTE_15", [15]="MAPSEC_ROUTE_14" }, + [13] = { [4]="MAPSEC_ROUTE_21", [12]="MAPSEC_ROUTE_19" }, + [14] = { [4]="MAPSEC_CINNABAR_ISLAND", [5]="MAPSEC_ROUTE_20", [6]="MAPSEC_ROUTE_20", [7]="MAPSEC_ROUTE_20", [8]="MAPSEC_ROUTE_20", [9]="MAPSEC_ROUTE_20", [10]="MAPSEC_ROUTE_20", [11]="MAPSEC_ROUTE_20", [12]="MAPSEC_ROUTE_19" }, +} + +-- Dungeon layer [y][x] 0-indexed layout from pokefirered LAYER_DUNGEON +RegionMapExtract.DUNGEON_GRID = { + [3] = { [9] = "MAPSEC_MT_MOON", [14] = "MAPSEC_CERULEAN_CAVE", [18] = "MAPSEC_ROCK_TUNNEL" }, + [4] = { [2] = "MAPSEC_KANTO_VICTORY_ROAD", [18] = "MAPSEC_POWER_PLANT" }, + [5] = { [4] = "MAPSEC_DIGLETTS_CAVE" }, + [6] = { [4] = "MAPSEC_VIRIDIAN_FOREST", [18] = "MAPSEC_POKEMON_TOWER" }, + [9] = { [15] = "MAPSEC_DIGLETTS_CAVE" }, + [12] = { [12] = "MAPSEC_KANTO_SAFARI_ZONE" }, + [14] = { [4] = "MAPSEC_POKEMON_MANSION", [8] = "MAPSEC_SEAFOAM_ISLANDS" }, +} + +-- Host Map ID -> (x, y) grid coords for player icon +RegionMapExtract.HOST_MAP_TO_GRID = { + PALLET_TOWN = { 4, 11 }, + REDS_HOUSE_1F = { 4, 11 }, + REDS_HOUSE_2F = { 4, 11 }, + BLUES_HOUSE = { 4, 11 }, + OAKS_LAB = { 4, 11 }, + VIRIDIAN_CITY = { 4, 8 }, + PEWTER_CITY = { 4, 4 }, + CERULEAN_CITY = { 14, 3 }, + LAVENDER_TOWN = { 18, 6 }, + VERMILION_CITY = { 14, 9 }, + CELADON_CITY = { 11, 6 }, + FUCHSIA_CITY = { 12, 12 }, + CINNABAR_ISLAND = { 4, 14 }, + INDIGO_PLATEAU = { 2, 3 }, + SAFFRON_CITY = { 14, 6 }, + ROUTE_1 = { 4, 9 }, + ROUTE_2 = { 4, 5 }, + ROUTE_3 = { 6, 4 }, + ROUTE_4 = { 11, 3 }, + ROUTE_5 = { 14, 4 }, + ROUTE_6 = { 14, 7 }, + ROUTE_7 = { 12, 6 }, + ROUTE_8 = { 16, 6 }, + ROUTE_9 = { 16, 3 }, + ROUTE_10 = { 18, 5 }, + ROUTE_11 = { 16, 9 }, + ROUTE_12 = { 18, 8 }, + ROUTE_13 = { 17, 11 }, + ROUTE_14 = { 15, 12 }, + ROUTE_15 = { 13, 12 }, + ROUTE_16 = { 9, 6 }, + ROUTE_17 = { 7, 8 }, + ROUTE_18 = { 9, 12 }, + ROUTE_19 = { 12, 13 }, + ROUTE_20 = { 8, 14 }, + ROUTE_21 = { 4, 13 }, + ROUTE_22 = { 3, 8 }, + ROUTE_23 = { 2, 5 }, + ROUTE_24 = { 14, 2 }, + ROUTE_25 = { 15, 1 }, + VIRIDIAN_FOREST = { 4, 6 }, + MT_MOON = { 9, 3 }, + ROCK_TUNNEL = { 18, 3 }, + POWER_PLANT = { 18, 4 }, + POKEMON_TOWER = { 18, 6 }, + DIGLETTS_CAVE = { 4, 5 }, + KANTO_VICTORY_ROAD = { 2, 4 }, + POKEMON_MANSION = { 4, 14 }, + KANTO_SAFARI_ZONE = { 12, 12 }, + SEAFOAM_ISLANDS = { 8, 14 }, + CERULEAN_CAVE = { 14, 3 }, +} + +function RegionMapExtract.resolveLocation(mapId, mapSec) + if mapSec and RegionMapExtract.SECTION_NAMES[mapSec] then + local name = RegionMapExtract.SECTION_NAMES[mapSec] + -- 1. Check DUNGEON_GRID first for dungeon mapsecs + for y = 0, RegionMapExtract.MAP_HEIGHT - 1 do + local dRow = RegionMapExtract.DUNGEON_GRID[y] + if dRow then + for x = 0, RegionMapExtract.MAP_WIDTH - 1 do + if dRow[x] == mapSec then + return { x = x, y = y, name = name, mapsec = mapSec } + end + end + end + end + -- 2. Check overworld KANTO_GRID + for y = 0, RegionMapExtract.MAP_HEIGHT - 1 do + local row = RegionMapExtract.KANTO_GRID[y] + if row then + for x = 0, RegionMapExtract.MAP_WIDTH - 1 do + if row[x] == mapSec then + return { x = x, y = y, name = name, mapsec = mapSec } + end + end + end + end + end + + if mapId then + local u = tostring(mapId):upper() + local g = RegionMapExtract.HOST_MAP_TO_GRID[u] + if g then + local dSec = RegionMapExtract.DUNGEON_GRID[g[2]] and RegionMapExtract.DUNGEON_GRID[g[2]][g[1]] + local oSec = RegionMapExtract.KANTO_GRID[g[2]] and RegionMapExtract.KANTO_GRID[g[2]][g[1]] + local sec = dSec or oSec + if RegionMapExtract.SECTION_NAMES["MAPSEC_" .. u] then + sec = "MAPSEC_" .. u + end + local name = sec and RegionMapExtract.SECTION_NAMES[sec] or u:gsub("_", " ") + return { x = g[1], y = g[2], name = name, mapsec = sec } + end + -- Sub-locations / Buildings lookup + for hostKey, grid in pairs(RegionMapExtract.HOST_MAP_TO_GRID) do + if u:find(hostKey, 1, true) then + local dSec = RegionMapExtract.DUNGEON_GRID[grid[2]] and RegionMapExtract.DUNGEON_GRID[grid[2]][grid[1]] + local oSec = RegionMapExtract.KANTO_GRID[grid[2]] and RegionMapExtract.KANTO_GRID[grid[2]][grid[1]] + local sec = dSec or oSec + if RegionMapExtract.SECTION_NAMES["MAPSEC_" .. hostKey] then + sec = "MAPSEC_" .. hostKey + end + local name = sec and RegionMapExtract.SECTION_NAMES[sec] or hostKey:gsub("_", " ") + return { x = grid[1], y = grid[2], name = name, mapsec = sec } + end + end + end + + return { x = 4, y = 11, name = "PALLET TOWN", mapsec = "MAPSEC_PALLET_TOWN" } +end + +return RegionMapExtract diff --git a/src/import/gba/tm_case_extract.lua b/src/import/gba/tm_case_extract.lua new file mode 100644 index 00000000..eae101ed --- /dev/null +++ b/src/import/gba/tm_case_extract.lua @@ -0,0 +1,323 @@ +-- Bake FRLG TM Case chrome from ROM into CacheFS (data/generated/gba/items/tm_case/). +-- Source: pret graphics/tm_case/ (tm_case.4bpp.lz, menu.bin.lz, tm_case.bin.lz, menu_male.gbapal.lz, menu_female.gbapal.lz, disc.4bpp.lz, disc_types_1.gbapal.lz, disc_types_2.gbapal.lz, hm.4bpp). + +local Versions = require("src.import.gba.versions") +local Lz77 = require("src.import.gba.lz77") + +local TmCaseExtract = {} + +TmCaseExtract.CACHE_SUB = "items/tm_case" +TmCaseExtract.FORMAT_VERSION = 1 + +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 + end + return "data/generated/gba" +end + +local function bgr555_to_rgb8(c) + c = (tonumber(c) or 0) % 32768 + local r5 = c % 32 + local g5 = math.floor(c / 32) % 32 + local b5 = math.floor(c / 1024) % 32 + return math.floor(r5 * 255 / 31 + 0.5), + math.floor(g5 * 255 / 31 + 0.5), + math.floor(b5 * 255 / 31 + 0.5) +end + +local function byte_len(buf) + if type(buf) ~= "table" then return 0 end + return buf._len or #buf +end + +local function decode_tile_4bpp(tileBytes, out, baseX, baseY, stride, hflip, vflip) + for row = 0, 7 do + local srcRow = vflip and (7 - row) or row + for bx = 0, 3 do + local byte = tileBytes[srcRow * 4 + bx + 1] or 0 + local p0 = byte % 16 + local p1 = math.floor(byte / 16) % 16 + local x0 = bx * 2 + local x1 = x0 + 1 + if hflip then + x0, x1 = 7 - x0, 7 - x1 + end + out[(baseY + row) * stride + (baseX + x0) + 1] = p0 + out[(baseY + row) * stride + (baseX + x1) + 1] = p1 + end + end +end + +local function load_pal_banks(bytes, count) + local banks = {} + local n = count or math.floor(byte_len(bytes) / 32) + for b = 0, n - 1 do + local colors = {} + local off = b * 32 + for c = 0, 15 do + local i = off + c * 2 + 1 + colors[c] = (bytes[i] or 0) + (bytes[i + 1] or 0) * 256 + end + banks[b] = colors + end + return banks +end + +--- Render BG2 base background (menu tilemap). +local function bake_tm_case_bg_rgba(gfx, palBytes, mapMenu, W, H) + local tileCount = math.floor(byte_len(gfx) / 32) + local banks = load_pal_banks(palBytes) + local mapW = 32 + local indices, pals = {}, {} + for i = 1, W * H do indices[i] = 0; pals[i] = 0 end + + local tilesH = math.min(32, math.floor(H / 8)) + local tilesW = math.min(32, math.floor(W / 8)) + for ty = 0, tilesH - 1 do + for tx = 0, tilesW - 1 do + local mi = (ty * mapW + tx) * 2 + 1 + local entry = (mapMenu[mi] or 0) + (mapMenu[mi + 1] or 0) * 256 + local tileId = entry % 1024 + local hflip = math.floor(entry / 1024) % 2 == 1 + local vflip = math.floor(entry / 2048) % 2 == 1 + local palNum = math.floor(entry / 4096) % 16 + if tileId < tileCount then + local tile = {} + local base = tileId * 32 + for i = 1, 32 do tile[i] = gfx[base + i] or 0 end + local tmp = {} + for i = 1, 64 do tmp[i] = 0 end + decode_tile_4bpp(tile, tmp, 0, 0, 8, hflip, vflip) + for row = 0, 7 do + for col = 0, 7 do + local px, py = tx * 8 + col, ty * 8 + row + if px < W and py < H then + local di = py * W + px + 1 + indices[di] = tmp[row * 8 + col + 1] or 0 + pals[di] = palNum + end + end + end + end + end + end + + local chunks = {} + for i = 1, W * H do + local bank = banks[pals[i]] or banks[0] or {} + local col = bank[indices[i]] or 0 + local r, g, b = bgr555_to_rgb8(col) + chunks[i] = string.char(r, g, b, 255) + end + return table.concat(chunks) +end + +--- Render BG1 pocket cover overlay (foreground layer with alpha transparency). +local function bake_tm_case_cover_rgba(gfx, palBytes, mapBg, W, H) + local tileCount = math.floor(byte_len(gfx) / 32) + local banks = load_pal_banks(palBytes) + local mapW = 32 + local indices, pals, hasTile = {}, {}, {} + for i = 1, W * H do indices[i] = 0; pals[i] = 0; hasTile[i] = false end + + local tilesH = math.min(32, math.floor(H / 8)) + local tilesW = math.min(32, math.floor(W / 8)) + for ty = 0, tilesH - 1 do + for tx = 0, tilesW - 1 do + local mi = (ty * mapW + tx) * 2 + 1 + local entry = (mapBg[mi] or 0) + (mapBg[mi + 1] or 0) * 256 + local tileId = entry % 1024 + local hflip = math.floor(entry / 1024) % 2 == 1 + local vflip = math.floor(entry / 2048) % 2 == 1 + local palNum = math.floor(entry / 4096) % 16 + if tileId > 0 and tileId < tileCount then + local tile = {} + local base = tileId * 32 + for i = 1, 32 do tile[i] = gfx[base + i] or 0 end + local tmp = {} + for i = 1, 64 do tmp[i] = 0 end + decode_tile_4bpp(tile, tmp, 0, 0, 8, hflip, vflip) + for row = 0, 7 do + for col = 0, 7 do + local px, py = tx * 8 + col, ty * 8 + row + if px < W and py < H then + local di = py * W + px + 1 + local idx = tmp[row * 8 + col + 1] or 0 + if idx ~= 0 then + indices[di] = idx + pals[di] = palNum + hasTile[di] = true + end + end + end + end + end + end + end + + local chunks = {} + for i = 1, W * H do + if not hasTile[i] then + chunks[i] = string.char(0, 0, 0, 0) + else + local bank = banks[pals[i]] or banks[0] or {} + local col = bank[indices[i]] or 0 + local r, g, b = bgr555_to_rgb8(col) + chunks[i] = string.char(r, g, b, 255) + end + end + return table.concat(chunks) +end + +--- Render 32x32 disc sprite with a given type palette (TM or HM). +local function bake_disc_rgba(discGfx, palBank, isHm) + local W, H = 32, 32 + local tileCount = math.floor(byte_len(discGfx) / 32) + local tileOffset = isHm and 16 or 0 + local pixels = {} + for i = 1, W * H do pixels[i] = 0 end + local ti = 0 + for ty = 0, 3 do + for tx = 0, 3 do + local tileIndex = tileOffset + ti + if tileIndex < tileCount then + local tile = {} + local base = tileIndex * 32 + for i = 1, 32 do tile[i] = discGfx[base + i] or 0 end + decode_tile_4bpp(tile, pixels, tx * 8, ty * 8, W, false, false) + end + ti = ti + 1 + end + end + + local chunks = {} + for i = 1, W * H do + local idx = pixels[i] or 0 + if idx == 0 then + chunks[i] = string.char(0, 0, 0, 0) + else + local r, g, b = bgr555_to_rgb8(palBank[idx] or 0) + chunks[i] = string.char(r, g, b, 255) + end + end + return table.concat(chunks) +end + +--- Render 16x12 raw HM icon (2x2 8x8 tiles cropped). +local function bake_hm_icon_rgba(hmGfx, palBank) + local W, H = 16, 12 + local pixels = {} + for i = 1, 16 * 16 do pixels[i] = 0 end + local ti = 0 + for ty = 0, 1 do + for tx = 0, 1 do + local tile = {} + local base = ti * 32 + for i = 1, 32 do tile[i] = hmGfx[base + i] or 0 end + decode_tile_4bpp(tile, pixels, tx * 8, ty * 8, 16, false, false) + ti = ti + 1 + end + end + + local chunks = {} + for y = 0, H - 1 do + for x = 0, W - 1 do + local idx = pixels[y * 16 + x + 1] or 0 + if idx == 0 then + chunks[#chunks + 1] = string.char(0, 0, 0, 0) + else + local r, g, b = bgr555_to_rgb8(palBank[idx] or 0) + chunks[#chunks + 1] = string.char(r, g, b, 255) + end + end + end + return table.concat(chunks) +end + +function TmCaseExtract.run(rom, cache, opts) + opts = opts or {} + local cacheRoot = opts.cacheRoot or default_cache_root() + local root = cacheRoot .. "/" .. TmCaseExtract.CACHE_SUB + local W = opts.width or 240 + local H = opts.height or 160 + + local function get(i) return rom:get(i) end + + local gfx = Lz77.decompress(get, Versions.TM_CASE_BG_GFX) + local mapBg = Lz77.decompress(get, Versions.TM_CASE_BG_TILEMAP) + local mapMenu = Lz77.decompress(get, Versions.TM_CASE_MENU_TILEMAP) + local palMale = Lz77.decompress(get, Versions.TM_CASE_MENU_MALE_PAL) + local palFemale = Lz77.decompress(get, Versions.TM_CASE_MENU_FEMALE_PAL) + + -- 1. BG2 Base Backgrounds + local bgMale = bake_tm_case_bg_rgba(gfx, palMale, mapMenu, W, H) + local bgFemale = bake_tm_case_bg_rgba(gfx, palFemale, mapMenu, W, H) + cache:write(root .. "/bg_male.rgba", bgMale) + cache:write(root .. "/bg_female.rgba", bgFemale) + + -- 2. BG1 Foreground Pocket Covers (Priority 0 over sprites) + local coverMale = bake_tm_case_cover_rgba(gfx, palMale, mapBg, W, H) + local coverFemale = bake_tm_case_cover_rgba(gfx, palFemale, mapBg, W, H) + cache:write(root .. "/cover_male.rgba", coverMale) + cache:write(root .. "/cover_female.rgba", coverFemale) + + -- 3. Discs (TM + HM for each type 0..16) + local discGfx = Lz77.decompress(get, Versions.TM_CASE_DISC_GFX) + local discPal1 = Lz77.decompress(get, Versions.TM_CASE_DISC_TYPES1_PAL) + local discPal2 = Lz77.decompress(get, Versions.TM_CASE_DISC_TYPES2_PAL) + local banks1 = load_pal_banks(discPal1, 16) + local banks2 = load_pal_banks(discPal2, 1) + + local discBanks = {} + for b = 0, 15 do discBanks[b] = banks1[b] end + discBanks[16] = banks2[0] -- Dragon type + + for typeIdx = 0, 16 do + local bank = discBanks[typeIdx] or {} + local discRgba = bake_disc_rgba(discGfx, bank, false) + cache:write(string.format("%s/disc_%d.rgba", root, typeIdx), discRgba) + local hmDiscRgba = bake_disc_rgba(discGfx, bank, true) + cache:write(string.format("%s/disc_hm_%d.rgba", root, typeIdx), hmDiscRgba) + end + + -- 4. HM Icon + local hmGfx = {} + for i = 1, 96 do hmGfx[i] = rom:get(Versions.TM_CASE_HM_GFX + i - 1) end + local hmRgba = bake_hm_icon_rgba(hmGfx, banks1[0] or {}) + cache:write(root .. "/hm_icon.rgba", hmRgba) + + local manifest = string.format([[ +return { + format_version = %d, + width = %d, + height = %d, + discW = 32, + discH = 32, + discCount = 17, + hmW = 16, + hmH = 12, +} +]], TmCaseExtract.FORMAT_VERSION, W, H) + cache:write(root .. "/manifest.lua", manifest) + + print(string.format("[tm_case_extract] TM Case chrome baked (2 BGs, 2 Covers, 17 TM discs, 17 HM discs, HM icon) -> %s", root)) + return { + root = root, + width = W, + height = H, + discCount = 17, + } +end + +function TmCaseExtract.ready(cache, cacheRoot) + local root = (cacheRoot or default_cache_root()) .. "/" .. TmCaseExtract.CACHE_SUB + if cache and cache.exists then + return cache:exists(root .. "/bg_male.rgba") and cache:exists(root .. "/cover_male.rgba") and cache:exists(root .. "/disc_0.rgba") + end + return false +end + +return TmCaseExtract + diff --git a/src/import/gba/trainer_card_extract.lua b/src/import/gba/trainer_card_extract.lua index b92c25fb..f2702c45 100644 --- a/src/import/gba/trainer_card_extract.lua +++ b/src/import/gba/trainer_card_extract.lua @@ -133,12 +133,16 @@ local function bake_badges_rgba(badgeTiles, palBytes) local pixels = {} for i = 1, W * H do pixels[i] = 0 end - local tileOffsets = { {0, 0}, {8, 0}, {0, 8}, {8, 8} } for badge = 0, 7 do - local baseTile = badge * 4 - for tIdx = 1, 4 do - local ox, oy = tileOffsets[tIdx][1], tileOffsets[tIdx][2] - local tileNum = baseTile + (tIdx - 1) + -- 4 tiles per badge: TL (badge*2), TR (badge*2+1), BL (16+badge*2), BR (16+badge*2+1) + local tiles = { + { badge * 2, 0, 0 }, + { badge * 2 + 1, 8, 0 }, + { 16 + badge * 2, 0, 8 }, + { 16 + badge * 2 + 1, 8, 8 }, + } + for _, t in ipairs(tiles) do + local tileNum, ox, oy = t[1], t[2], t[3] local base = tileNum * 32 local tile = {} for i = 1, 32 do tile[i] = badgeTiles[base + i] or 0 end diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 0bdbc5c5..90e5147b 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -385,7 +385,7 @@ Versions.ANIM_CALLBACK_NAMES = { [0x080B8B6D] = "BonemerangProjectile", [0x080B8C55] = "BoneHitProjectile", [0x080B8D59] = "MudSportDirt", - [0x080B99D5] = "GrantingStars", + [0x080B99D5] = "SimplePaletteBlend", [0x080B9A7D] = "ComplexPaletteBlend", [0x080BA27D] = "ShakeMonOrBattleTerrain", [0x080BA561] = "HitSplatBasic", @@ -452,6 +452,7 @@ Versions.ANIM_TASK_NAMES = { [0x080A28C5] = "SporeDoubleBattle", [0x080A39C1] = "ShakeMon", [0x080A41C5] = "BlendBattleAnimPalExclude", + [0x080A5695] = "SpitUpDeformMon", [0x080A5CD5] = "ConversionAlphaBlend", [0x080A5DE1] = "Conversion2AlphaBlend", [0x080A5FC1] = "MoonlightEndFade", @@ -467,6 +468,10 @@ Versions.ANIM_TASK_NAMES = { [0x080A8639] = "ThrashMoveMonHorizontal", [0x080A86A5] = "ThrashMoveMonVertical", [0x080A8875] = "SketchDrawMon", + [0x080A917D] = "RockMonBackAndForth", + [0x080A9211] = "FadeScreenToWhite", + [0x080A939D] = "AttackerPunchWithTrace", + [0x080A94AD] = "DoubleTeam", [0x080A96B5] = "LoadMusicNotesPals", [0x080A9761] = "FreeMusicNotesPals", [0x080A98B1] = "FakeOut", @@ -515,6 +520,7 @@ Versions.ANIM_TASK_NAMES = { [0x080B58AD] = "NightmareClone", [0x080B5AAD] = "SpiteTargetShadow", [0x080B6021] = "DestinyBondWhiteShadow", + [0x080B63B5] = "NightmareClone", [0x080B68C9] = "GrudgeFlames", [0x080B75E1] = "ElectricChargingParticles", [0x080B78E1] = "AttackerFadeToInvisible", @@ -527,6 +533,8 @@ Versions.ANIM_TASK_NAMES = { [0x080B86ED] = "BlendBattleAnimPal", [0x080B8A75] = "ShakeMon2", [0x080B8B39] = "GetIsDoomDesireHitTurn", + [0x080B8E95] = "DigDownMovement", + [0x080B90ED] = "DigUpMovement", [0x080B94B5] = "HorizontalShake", [0x080B97D9] = "IsPowerOver99", [0x080B9801] = "PositionFissureBgOnBattler", @@ -542,11 +550,13 @@ Versions.ANIM_TASK_NAMES = { [0x080BAA21] = "AllocBackupPalBuffer", [0x080BAB39] = "HardwarePaletteFade", [0x080BAB99] = "TraceMonBlended", + [0x080BACED] = "TraceMonBlended", [0x080BB661] = "Flash", [0x080BB7DD] = "StartSinAnimTimer", [0x080BB82D] = "ShakeMon", [0x080BB921] = "GetAttackerSide", [0x080BB94D] = "MusicNotesClearRainbowBlend", + [0x080BB979] = "BlendPalInAndOutByTag", [0x080BB9B1] = "BlendPalInAndOutByTag", [0x080BBDF1] = "GetBattleTerrain", [0x080BBE11] = "ShakeBattleTerrain", @@ -561,8 +571,10 @@ Versions.ANIM_TASK_NAMES = { [0x080DD06D] = "SoundTask_PlayCryHighPitch", [0x080DD149] = "SoundTask_PlayDoubleCry", [0x080DD2F5] = "SoundTask_WaitForCry", + [0x080DD335] = "SoundTask_PlaySE1WithPanning", [0x080DD3DD] = "SoundTask_PlaySE1WithPanning", [0x080DD411] = "ShakeMon", + [0x080E0489] = "FlashAnimTagWithColor", [0x080DD445] = "SoundTask_AdjustPanningVar", [0x080DE34D] = "SmokescreenImpact", [0x080DE6F1] = "SetPsychicBackground", @@ -579,7 +591,7 @@ Versions.ANIM_TASK_NAMES = { [0x080DF965] = "SwallowDeformMon", [0x080DF9BD] = "TransformMon", [0x080DFC51] = "MorningSunLightBeam", - [0x080E0559] = "CycleMagicalLeafPal", + [0x080E0559] = "RockMonBackAndForth", [0x080E0851] = "FlailMovement", [0x080E0B01] = "PainSplitMovement", [0x080E0FB9] = "RolePlaySilhouette", @@ -742,6 +754,25 @@ Versions.BAG_SPRITE_PAL = 0xE84560 -- gBag_Pal Versions.BAG_SWAP_GFX = 0xE84588 -- gSwapLine_Gfx Versions.BAG_SWAP_PAL = 0xE845C8 -- gSwapLine_Pal +-- TM Case (LZ-compressed & raw; FireRed USA 1.0). +Versions.TM_CASE_BG_GFX = 0xE845D8 -- gTMCase_Gfx (LZ 4bpp, 2912 bytes) +Versions.TM_CASE_MENU_TILEMAP = 0xE84A24 -- gTMCaseMenu_Tilemap (LZ 32x32, 2048 bytes) +Versions.TM_CASE_BG_TILEMAP = 0xE84B70 -- gTMCase_Tilemap (LZ 32x32, 2048 bytes) +Versions.TM_CASE_MENU_MALE_PAL = 0xE84CB0 -- gTMCaseMenu_Male_Pal (LZ 4 banks, 128 bytes) +Versions.TM_CASE_MENU_FEMALE_PAL = 0xE84D20 -- gTMCaseMenu_Female_Pal (LZ 4 banks, 128 bytes) +Versions.TM_CASE_DISC_GFX = 0xE84D90 -- gTMCaseDisc_Gfx (LZ 4bpp, 1024 bytes) +Versions.TM_CASE_DISC_TYPES1_PAL = 0xE84F20 -- gTMCaseDiscTypes1_Pal (LZ 16 banks, 512 bytes) +Versions.TM_CASE_DISC_TYPES2_PAL = 0xE85068 -- gTMCaseDiscTypes2_Pal (LZ 1 bank, 32 bytes) +Versions.TM_CASE_HM_GFX = 0xE99138 -- gTMCaseHM_Gfx (raw 4bpp, 96 bytes) + +-- Berry Pouch (LZ-compressed; FireRed USA 1.0). +Versions.BERRY_POUCH_SPRITE_GFX = 0xE8560C -- gBerryPouchSpriteTiles (LZ 4bpp 64x64, 2048 bytes) +Versions.BERRY_POUCH_BG_GFX = 0xE859D0 -- gBerryPouchBgGfx (LZ 4bpp, 1664 bytes) +Versions.BERRY_POUCH_BG_PAL = 0xE85BA4 -- gBerryPouchBgPals (LZ 3 banks, 96 bytes) +Versions.BERRY_POUCH_BG_PAL_FEMALE = 0xE85BF4 -- gBerryPouchBgPal0FemaleOverride (LZ 1 bank, 32 bytes) +Versions.BERRY_POUCH_SPRITE_PAL = 0xE85C1C -- gBerryPouchSpritePalette (LZ 1 bank, 32 bytes) +Versions.BERRY_POUCH_BG_TILEMAP = 0xE85C44 -- gBerryPouchBg1Tilemap (LZ 32x32, 2048 bytes) + -- Trainer Card (LZ-compressed & raw; FireRed USA 1.0). Versions.TRAINER_CARD_BG_TILES = 0xE991F8 -- gKantoTrainerCard_Gfx (LZ 4bpp, 6144 bytes) Versions.TRAINER_CARD_FRONT_MAP = 0x3CC6F0 -- sKantoTrainerCardFront_Tilemap (LZ 30x20, 1200 bytes) diff --git a/src/ui/game3/bag_menu.lua b/src/ui/game3/bag_menu.lua index d8026569..2e05c5a5 100644 --- a/src/ui/game3/bag_menu.lua +++ b/src/ui/game3/bag_menu.lua @@ -54,7 +54,7 @@ function BagMenu.isOpen() end function BagMenu.currentPocket() - return ItemsData.POCKET_ORDER[BagMenu.pocketIdx] or "ITEMS" + return ItemsData.BAG_POCKET_ORDER[BagMenu.pocketIdx] or "ITEMS" end function BagMenu.list(pocket) @@ -141,7 +141,7 @@ function BagMenu.show(sessionBag, opts) BagMenu.tossQty = 1 BagMenu._onClose = opts.onClose if opts.pocket then - for i, p in ipairs(ItemsData.POCKET_ORDER) do + for i, p in ipairs(ItemsData.BAG_POCKET_ORDER) do if p == opts.pocket then BagMenu.pocketIdx = i; break end end elseif BagMenu._battle then @@ -257,7 +257,38 @@ function BagMenu.handleInput(input) return end else - if ItemUse.needsPartyTarget(row.id) then + local numId = ItemsData.toNumericId(row.id) + if numId == ItemsData.ITEM_TM_CASE or row.id == "TM_CASE" then + local savedState = { pocketIdx = BagMenu.pocketIdx, cursor = BagMenu.cursor, scroll = BagMenu.scroll } + local TmCase = require("src.ui.game3.tm_case") + TmCase.show(BagMenu._session, BagMenu._bag, { + session = BagMenu._session, + bag = BagMenu._bag, + onClose = function() + BagMenu.pocketIdx = savedState.pocketIdx + BagMenu.cursor = savedState.cursor + BagMenu.scroll = savedState.scroll + BagMenu.mode = "list" + clamp_cursor() + end, + }) + return + elseif numId == ItemsData.ITEM_BERRY_POUCH or row.id == "BERRY_POUCH" then + local savedState = { pocketIdx = BagMenu.pocketIdx, cursor = BagMenu.cursor, scroll = BagMenu.scroll } + local BerryPouch = require("src.ui.game3.berry_pouch") + BerryPouch.show(BagMenu._session, BagMenu._bag, { + session = BagMenu._session, + bag = BagMenu._bag, + onClose = function() + BagMenu.pocketIdx = savedState.pocketIdx + BagMenu.cursor = savedState.cursor + BagMenu.scroll = savedState.scroll + BagMenu.mode = "list" + clamp_cursor() + end, + }) + return + elseif ItemUse.needsPartyTarget(row.id) then if #party == 0 then BagMenu.mode = "message" BagMenu.messageText = "There is no POKéMON." @@ -353,12 +384,12 @@ function BagMenu.handleInput(input) -- list mode if input:wasPressed("left") or input:wasPressed("l") then - BagMenu.pocketIdx = ((BagMenu.pocketIdx - 2) % #ItemsData.POCKET_ORDER) + 1 + BagMenu.pocketIdx = ((BagMenu.pocketIdx - 2) % #ItemsData.BAG_POCKET_ORDER) + 1 BagMenu.cursor = 1 BagMenu.scroll = 0 se(5) elseif input:wasPressed("right") or input:wasPressed("r") then - BagMenu.pocketIdx = (BagMenu.pocketIdx % #ItemsData.POCKET_ORDER) + 1 + BagMenu.pocketIdx = (BagMenu.pocketIdx % #ItemsData.BAG_POCKET_ORDER) + 1 BagMenu.cursor = 1 BagMenu.scroll = 0 se(5) @@ -384,6 +415,21 @@ function BagMenu.handleInput(input) refresh_actions() se(5) end + elseif input:wasPressed("select") and not BagMenu._battle then + local rows = clamp_cursor() + local row = rows[BagMenu.cursor] + if row and BagMenu.currentPocket() == "KEY_ITEMS" then + local info = row.info or ItemsData.info(row.id) + local registrable = info and (tonumber(info.registrability) or 0) > 0 + if registrable and BagMenu._session then + if BagMenu._session.registeredItem == row.id then + BagMenu._session.registeredItem = nil + else + BagMenu._session.registeredItem = row.id + end + se(5) + end + end elseif input:wasPressed("b") or input:wasPressed("start") then se(9) BagMenu.close() diff --git a/src/ui/game3/berry_pouch.lua b/src/ui/game3/berry_pouch.lua new file mode 100644 index 00000000..09216d63 --- /dev/null +++ b/src/ui/game3/berry_pouch.lua @@ -0,0 +1,466 @@ +-- FRLG Berry Pouch UI 1:1 with pokefirered (berry_pouch.c). +-- +-- Window Layouts (pokefirered/src/berry_pouch.c): +-- WIN_LIST: tilemapLeft=11, tilemapTop=1, width=18, height=14 -> (88, 8, 144, 112), 7 visible rows, pitch=16 +-- WIN_DESCRIPTION: tilemapLeft=5, tilemapTop=16, width=25, height=4 -> (40, 128, 200, 32), text at (40, 130) +-- WIN_HEADER: tilemapLeft=1, tilemapTop=1, width=9, height=2 -> (8, 8, 72, 16), text centered at (tx, 9) +-- WIN_SELECTED: tilemapLeft=6, tilemapTop=15, width=14, height=4 -> (48, 120, 112, 32) +-- WIN_CONTEXT: tilemapLeft=22, tilemapTop=11, width=7, height=8 -> (176, 88, 56, 64) +-- WIN_TOSS_LABEL: tilemapLeft=6, tilemapTop=15, width=16, height=4 -> (48, 120, 128, 32) +-- WIN_TOSS_QTY: tilemapLeft=24, tilemapTop=15, width=5, height=4 -> (192, 120, 40, 32) +-- WIN_TOSS_PROMPT: tilemapLeft=6, tilemapTop=15, width=15, height=4 -> (48, 120, 120, 32) +-- WIN_YES_NO: tilemapLeft=23, tilemapTop=15, width=6, height=4 -> (184, 120, 48, 32) +-- WIN_MSG: tilemapLeft=2, tilemapTop=15, width=26, height=4 -> (16, 120, 208, 32) +-- +-- Sprites: +-- Berry Pouch Sprite: 64x64 centered at (40, 76) -> top-left (8, 44), wobbles on open and cursor move. +-- Item Icon Sprite: 24x24 centered in 32x32 at (24, 147) -> top-left (12, 135). + +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local ItemsData = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local ItemUse = require("src.core.game3.item_use") + +local BerryPouch = {} + +BerryPouch.open = false +BerryPouch.cursor = 1 +BerryPouch.scroll = 0 +BerryPouch.mode = "list" -- "list" | "action" | "toss_select" | "toss_confirm" | "message" +BerryPouch.actionCursor = 1 +BerryPouch.yesNoCursor = 1 +BerryPouch.tossQty = 1 +BerryPouch.messageText = nil +BerryPouch.wobbleTimer = 0 + +local VISIBLE = 7 +local ACTIONS = { "USE", "GIVE", "TOSS", "EXIT" } + +local function se(id) + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio and Audio.playSe then Audio.playSe(id) end + end) +end + +function BerryPouch.isOpen() + return BerryPouch.open +end + +function BerryPouch.list() + local bag = BerryPouch._bag + if not bag then return {} end + local rows = Bag.listPocket(bag, "BERRY_POUCH") + return rows or {} +end + +local function clamp_cursor() + local rows = BerryPouch.list() + local total = #rows + 1 -- berries + CLOSE option + if total < 1 then total = 1 end + + if BerryPouch.cursor > total then BerryPouch.cursor = total end + if BerryPouch.cursor < 1 then BerryPouch.cursor = 1 end + + if BerryPouch.cursor <= BerryPouch.scroll then + BerryPouch.scroll = BerryPouch.cursor - 1 + end + if BerryPouch.cursor > BerryPouch.scroll + VISIBLE then + BerryPouch.scroll = BerryPouch.cursor - VISIBLE + end + if BerryPouch.scroll < 0 then BerryPouch.scroll = 0 end + local maxScroll = math.max(0, total - VISIBLE) + if BerryPouch.scroll > maxScroll then BerryPouch.scroll = maxScroll end + + return rows, total +end + +function BerryPouch.show(session, bag, opts) + opts = opts or {} + BerryPouch.open = true + BerryPouch._session = session or opts.session + BerryPouch._bag = bag or opts.bag or (session and session.bag) + BerryPouch._onClose = opts.onClose + BerryPouch.cursor = opts.cursor or 1 + BerryPouch.scroll = opts.scroll or 0 + BerryPouch.mode = "list" + BerryPouch.actionCursor = 1 + BerryPouch.yesNoCursor = 1 + BerryPouch.tossQty = 1 + BerryPouch.messageText = nil + BerryPouch.wobbleTimer = 0.25 -- Authentically trigger affine wobble on open + clamp_cursor() + Stack.push("berry_pouch", BerryPouch, { hideBelow = true }) +end + +function BerryPouch.close() + BerryPouch.open = false + Stack.pop("berry_pouch") + local cb = BerryPouch._onClose + BerryPouch._onClose = nil + if cb then cb() end +end + +function BerryPouch.update(dt) + if BerryPouch.wobbleTimer > 0 then + BerryPouch.wobbleTimer = math.max(0, BerryPouch.wobbleTimer - (dt or 0.016)) + end +end + +function BerryPouch.handleInput(input) + local rows, total = clamp_cursor() + local row = rows[BerryPouch.cursor] + + -- 1. Toss Quantity Select Mode (Task_Toss_SelectMultiple) + if BerryPouch.mode == "toss_select" then + local maxQ = row and (tonumber(row.qty) or 1) or 1 + if input:wasPressed("up") or input:wasPressed("right") then + if BerryPouch.tossQty < maxQ then + BerryPouch.tossQty = BerryPouch.tossQty + 1 + else + BerryPouch.tossQty = 1 -- wrap around to 1 + end + se(5) + elseif input:wasPressed("down") or input:wasPressed("left") then + if BerryPouch.tossQty > 1 then + BerryPouch.tossQty = BerryPouch.tossQty - 1 + else + BerryPouch.tossQty = maxQ -- wrap around to max + end + se(5) + elseif input:wasPressed("a") then + se(5) + BerryPouch.mode = "toss_confirm" + BerryPouch.yesNoCursor = 1 + elseif input:wasPressed("b") then + se(9) + BerryPouch.mode = "list" + end + return + end + + -- 2. Toss Confirmation Mode (Task_AskTossMultiple & CreateYesNoMenuWin3) + if BerryPouch.mode == "toss_confirm" then + if input:wasPressed("up") or input:wasPressed("down") then + BerryPouch.yesNoCursor = (BerryPouch.yesNoCursor == 1) and 2 or 1 + se(5) + elseif input:wasPressed("b") then + se(9) + BerryPouch.mode = "list" + elseif input:wasPressed("a") then + if BerryPouch.yesNoCursor == 1 then + -- YES: Toss items + se(5) + if row then + local bName = row.name or ItemsData.displayName(row.id) + Bag.remove(BerryPouch._bag, row.id, BerryPouch.tossQty) + BerryPouch.mode = "message" + BerryPouch.messageText = string.format("Threw away %d\n%s.", BerryPouch.tossQty, bName) + clamp_cursor() + else + BerryPouch.mode = "list" + end + else + -- NO: Cancel toss + se(9) + BerryPouch.mode = "list" + end + end + return + end + + -- 3. Message Mode + if BerryPouch.mode == "message" then + if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then + se(5) + BerryPouch.mode = "list" + BerryPouch.messageText = nil + clamp_cursor() + end + return + end + + -- 4. Context Action Menu Mode (Task_NormalContextMenu) + if BerryPouch.mode == "action" then + if input:wasPressed("up") then + BerryPouch.actionCursor = ((BerryPouch.actionCursor - 2) % #ACTIONS) + 1 + se(5) + elseif input:wasPressed("down") then + BerryPouch.actionCursor = (BerryPouch.actionCursor % #ACTIONS) + 1 + se(5) + elseif input:wasPressed("a") then + se(5) + local act = ACTIONS[BerryPouch.actionCursor] + local party = (BerryPouch._session and BerryPouch._session.party) or {} + if act == "EXIT" or not row then + BerryPouch.mode = "list" + elseif act == "USE" then + if ItemUse.needsPartyTarget(row.id) then + if #party == 0 then + BerryPouch.mode = "message" + BerryPouch.messageText = "There is no POKéMON." + else + local PartyMenu = require("src.ui.game3.party_menu") + PartyMenu.show(party, BerryPouch._session and BerryPouch._session.moveOverlay, { + session = BerryPouch._session, + bag = BerryPouch._bag, + item = row.id, + mode = "use", + onClose = function() + BerryPouch.mode = "list" + clamp_cursor() + end, + }) + end + else + BerryPouch.mode = "message" + BerryPouch.messageText = "OAK: This isn't the\ntime to use that!" + end + elseif act == "GIVE" then + if #party == 0 then + BerryPouch.mode = "message" + BerryPouch.messageText = "There is no POKéMON." + else + local PartyMenu = require("src.ui.game3.party_menu") + PartyMenu.show(party, BerryPouch._session and BerryPouch._session.moveOverlay, { + session = BerryPouch._session, + bag = BerryPouch._bag, + item = row.id, + mode = "give", + onClose = function() + BerryPouch.mode = "list" + clamp_cursor() + end, + }) + end + elseif act == "TOSS" then + local maxQ = row and (tonumber(row.qty) or 1) or 1 + if maxQ == 1 then + BerryPouch.tossQty = 1 + BerryPouch.mode = "toss_confirm" + BerryPouch.yesNoCursor = 1 + else + BerryPouch.tossQty = 1 + BerryPouch.mode = "toss_select" + end + end + elseif input:wasPressed("b") then + se(9) + BerryPouch.mode = "list" + end + return + end + + -- 5. List Navigation Mode (Task_BerryPouchMain) + if input:wasPressed("up") then + if total > 0 then + BerryPouch.cursor = ((BerryPouch.cursor - 2) % total) + 1 + BerryPouch.wobbleTimer = 0.25 + clamp_cursor() + se(5) + end + elseif input:wasPressed("down") then + if total > 0 then + BerryPouch.cursor = (BerryPouch.cursor % total) + 1 + BerryPouch.wobbleTimer = 0.25 + clamp_cursor() + se(5) + end + elseif input:wasPressed("left") or input:wasPressed("l") then + if total > 0 then + BerryPouch.cursor = math.max(1, BerryPouch.cursor - VISIBLE) + BerryPouch.wobbleTimer = 0.25 + clamp_cursor() + se(5) + end + elseif input:wasPressed("right") or input:wasPressed("r") then + if total > 0 then + BerryPouch.cursor = math.min(total, BerryPouch.cursor + VISIBLE) + BerryPouch.wobbleTimer = 0.25 + clamp_cursor() + se(5) + end + elseif input:wasPressed("a") then + if BerryPouch.cursor == total then + -- CLOSE option selected + se(9) + BerryPouch.close() + elseif row then + -- Berry selected + BerryPouch.mode = "action" + BerryPouch.actionCursor = 1 + se(5) + end + elseif input:wasPressed("b") or input:wasPressed("start") then + se(9) + BerryPouch.close() + end +end + +function BerryPouch.draw() + if not BerryPouch.open then return end + local rows, total = clamp_cursor() + local sel = rows[BerryPouch.cursor] + + local okC, BerryPouchChrome = pcall(require, "src.ui.game3.berry_pouch_chrome") + local hasChrome = okC and BerryPouchChrome and BerryPouchChrome.ready and BerryPouchChrome.ready() + local isFemale = false + local session = BerryPouch._session + if session and (session.gender == 1 or session.gender == "female" or session.playerGender == 1) then + isFemale = true + end + + -- 1. Background (BG 1) + if hasChrome then + BerryPouchChrome.drawBg(0, 0, { female = isFemale }) + else + -- Fallback Background (Berry Pouch Green/Teal Theme) + love.graphics.setColor(0.18, 0.48, 0.35, 1) + love.graphics.rectangle("fill", 0, 0, 240, 160) + + -- Header Frame + Window.stdFrame(Window.template(1, 1, 9, 2)) + -- List Menu Frame + Window.stdFrame(Window.template(11, 1, 18, 14)) + -- Description Frame + Window.stdFrame(Window.template(5, 16, 25, 4)) + end + + -- 2. Header (WIN 2: tilemapLeft=1, tilemapTop=1, width=9, height=2 -> 72px center at y=9) + local headerTitle = "BERRY POUCH" + local tw = FrlgFont.measure(headerTitle) + local tx = math.floor((72 - tw) / 2) + 8 + FrlgFont.draw(headerTitle, tx, 9, { colors = FrlgFont.COLOR.LIGHT }) + + -- 3. Berry Pouch Sprite (64x64 centered at (40, 72) -> top-left at (8, 40)) + local wobbleAngle = 0 + if BerryPouch.wobbleTimer > 0 then + -- 1:1 affine wobble oscillation (-2..+2 deltas) + wobbleAngle = math.sin((BerryPouch.wobbleTimer / 0.25) * math.pi * 4) * 0.08 + end + if hasChrome then + BerryPouchChrome.drawPouch(8, 40, wobbleAngle) + end + + -- 4. Item Icon Sprite (24x24 centered in 27x27 white box at (20, 143) -> top-left at (8, 131)) + if sel and BerryPouch.cursor <= #rows then + local okB, BagChrome = pcall(require, "src.ui.game3.bag_chrome") + if okB and BagChrome and BagChrome.drawItemIcon then + BagChrome.drawItemIcon(sel.id, 8, 131) + end + end + + -- 5. Description Box (WIN 1: tilemapLeft=5, tilemapTop=16, width=25, height=4 -> screen (40, 128, 200, 32)) + if BerryPouch.cursor == total then + local closeDesc = "The BERRY POUCH will be\nput away." + FrlgFont.draw(closeDesc, 40, 130, { colors = FrlgFont.COLOR.LIGHT, linePitch = 14 }) + elseif sel then + local desc = sel.description or ItemsData.description(sel.id) or "" + FrlgFont.draw(desc, 40, 130, { colors = FrlgFont.COLOR.LIGHT, linePitch = 14 }) + end + + -- 6. List Menu (WIN 0: tilemapLeft=11, tilemapTop=1, width=18, height=14 -> 7 visible rows, pitch=16) + if BerryPouch.scroll > 0 then + FrlgFont.draw("▲", 160, 8, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + if BerryPouch.scroll + VISIBLE < total then + FrlgFont.draw("▼", 160, 120, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + + for i = 1, VISIBLE do + local idx = BerryPouch.scroll + i + if idx > total then break end + local y = 10 + (i - 1) * 16 + + -- Selector Arrow at x = 89 + if idx == BerryPouch.cursor and BerryPouch.mode == "list" then + Window.cursorPx(89, y) + end + + if idx <= #rows then + local r = rows[idx] + local berryNum = ItemsData.berryNumber(r.id) or idx + -- №xx in FONT_SMALL at x = 97 + local noStr = string.format("№%02d", berryNum) + FrlgFont.draw(noStr, 97, y, { small = true, colors = FrlgFont.COLOR.NORMAL }) + + -- Berry Name in FONT_NORMAL at x = 121 (spaced after №xx) + local bName = r.name or ItemsData.displayName(r.id) + FrlgFont.draw(bName, 121, y, { colors = FrlgFont.COLOR.NORMAL }) + + -- Quantity ×%3d in FONT_SMALL at x = 198 + local qStr = string.format("×%3d", r.qty or 1) + FrlgFont.draw(qStr, 198, y, { small = true, colors = FrlgFont.COLOR.NORMAL }) + else + -- CLOSE option in FONT_NORMAL at x = 97 + FrlgFont.draw("CLOSE", 97, y, { colors = FrlgFont.COLOR.NORMAL }) + end + end + + -- 7. Context Menu (WIN 13: 22, 11, 7, 8) + Selected Message (WIN 6: 6, 15, 14, 4) + if BerryPouch.mode == "action" and sel then + local bName = sel.name or ItemsData.displayName(sel.id) + + -- WIN 6: Selected message + Window.stdFrame(Window.template(6, 15, 14, 4)) + local selMsg = string.format("%s is\nselected.", bName) + FrlgFont.draw(selMsg, 52, 124, { colors = FrlgFont.COLOR.NORMAL, linePitch = 14 }) + + -- WIN 13: Action menu + Window.stdFrame(Window.template(22, 11, 7, 8)) + for i, act in ipairs(ACTIONS) do + local rowY = 90 + (i - 1) * 16 + if i == BerryPouch.actionCursor then + Window.cursorPx(177, rowY) + end + FrlgFont.draw(act, 185, rowY, { colors = FrlgFont.COLOR.NORMAL }) + end + end + + -- 8. Toss Quantity Select UI (WIN 8: 6, 15, 16, 4 + WIN 0: 24, 15, 5, 4) + if BerryPouch.mode == "toss_select" and sel then + local bName = sel.name or ItemsData.displayName(sel.id) + + -- WIN 8: Prompt + Window.stdFrame(Window.template(6, 15, 16, 4)) + local tossMsg = string.format("Toss out how many\n%s?", bName) + FrlgFont.draw(tossMsg, 52, 122, { colors = FrlgFont.COLOR.NORMAL, linePitch = 14 }) + + -- WIN 0: Quantity with arrows + Window.stdFrame(Window.template(24, 15, 5, 4)) + local qStr = string.format("×%02d", BerryPouch.tossQty) + FrlgFont.draw(qStr, 196, 130, { small = true, colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("▲", 212, 122, { colors = FrlgFont.COLOR.DARK_GRAY }) + FrlgFont.draw("▼", 212, 144, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + + -- 9. Toss Confirmation Modal (WIN 7: 6, 15, 15, 4 + WIN 3: 23, 15, 6, 4) + if BerryPouch.mode == "toss_confirm" and sel then + -- WIN 7: Confirmation prompt + Window.stdFrame(Window.template(6, 15, 15, 4)) + local confMsg = string.format("Throw away %d of\nthis item?", BerryPouch.tossQty) + FrlgFont.draw(confMsg, 52, 124, { colors = FrlgFont.COLOR.NORMAL, linePitch = 14 }) + + -- WIN 3: YES / NO + Window.stdFrame(Window.template(23, 15, 6, 4)) + local yesY = 124 + local noY = 140 + if BerryPouch.yesNoCursor == 1 then + Window.cursorPx(185, yesY) + else + Window.cursorPx(185, noY) + end + FrlgFont.draw("YES", 193, yesY, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("NO", 193, noY, { colors = FrlgFont.COLOR.NORMAL }) + end + + -- 10. Dialogue Message Modal (WIN 5: 2, 15, 26, 4) + if BerryPouch.mode == "message" and BerryPouch.messageText then + Window.stdFrame(Window.template(2, 15, 26, 4)) + FrlgFont.draw(BerryPouch.messageText, 20, 124, { colors = FrlgFont.COLOR.NORMAL, linePitch = 14 }) + end +end + +return BerryPouch diff --git a/src/ui/game3/berry_pouch_chrome.lua b/src/ui/game3/berry_pouch_chrome.lua new file mode 100644 index 00000000..d89fd142 --- /dev/null +++ b/src/ui/game3/berry_pouch_chrome.lua @@ -0,0 +1,144 @@ +-- Berry Pouch chrome loader and renderer from CacheFS (items/berry_pouch/). + +local Extract = require("src.import.gba.extract_island1") +local BerryPouchExtract = require("src.import.gba.berry_pouch_extract") + +local BerryPouchChrome = {} + +BerryPouchChrome._cache = nil +BerryPouchChrome._bgMale = nil +BerryPouchChrome._bgFemale = nil +BerryPouchChrome._pouch = nil +BerryPouchChrome._manifest = nil +BerryPouchChrome._logged = false + +local function cache_root() + return Extract.CACHE_ROOT or "data/generated/gba" +end + +local function bp_root() + return cache_root() .. "/" .. BerryPouchExtract.CACHE_SUB +end + +local function read_bytes(rel) + local cache = BerryPouchChrome._cache + if cache and cache.read then + local d = cache:read(rel) + if type(d) == "string" and #d > 0 then return d end + end + local okD, Dataset = pcall(require, "src.core.game3.dataset") + if okD and Dataset and Dataset.cache then + local d = Dataset.cache():read(rel) + if type(d) == "string" and #d > 0 then return d end + end + local ok, CacheFs = pcall(require, "src.import.CacheFs") + if ok and CacheFs and CacheFs.readActive then + local d = CacheFs.readActive(rel) + if type(d) == "string" and #d > 0 then return d end + end + if love and love.filesystem and love.filesystem.read then + local d = love.filesystem.read(rel) + if type(d) == "string" and #d > 0 then return d end + local alt = "data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")) + d = love.filesystem.read(alt) + if type(d) == "string" and #d > 0 then return d end + end + local candidates = { + rel, + "data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")), + } + for _, p in ipairs(candidates) do + local f = io.open(p, "rb") + if f then + local d = f:read("*a") + f:close() + if d and #d > 0 then return d end + end + end + return nil +end + +local function rgba_to_image(rgba, w, h) + if not (love and love.image and love.graphics) then return nil end + local okD, data = pcall(love.image.newImageData, w, h) + if not okD or not data then return nil end + local ffiOk, ffi = pcall(require, "ffi") + if ffiOk and ffi and data.getFFIPointer then + local ptr = data:getFFIPointer() + ffi.copy(ptr, rgba, w * h * 4) + else + local p = 1 + for y = 0, h - 1 do + for x = 0, w - 1 do + local r = rgba:byte(p) / 255 + local g = rgba:byte(p + 1) / 255 + local b = rgba:byte(p + 2) / 255 + local a = rgba:byte(p + 3) / 255 + p = p + 4 + data:setPixel(x, y, r, g, b, a) + end + end + end + local img = love.graphics.newImage(data) + if img and img.setFilter then + img:setFilter("nearest", "nearest") + end + return img +end + +function BerryPouchChrome.ready() + if BerryPouchChrome._bgMale then return true end + local d = read_bytes(bp_root() .. "/bg_male.rgba") + return d ~= nil and #d > 0 +end + +function BerryPouchChrome.loadBg(female) + if female and BerryPouchChrome._bgFemale then return BerryPouchChrome._bgFemale end + if not female and BerryPouchChrome._bgMale then return BerryPouchChrome._bgMale end + local filename = female and "/bg_female.rgba" or "/bg_male.rgba" + local raw = read_bytes(bp_root() .. filename) + if not raw or #raw < 240 * 160 * 4 then return nil end + local img = rgba_to_image(raw, 240, 160) + if female then + BerryPouchChrome._bgFemale = img + else + BerryPouchChrome._bgMale = img + end + return img +end + +function BerryPouchChrome.loadPouch() + if BerryPouchChrome._pouch then return BerryPouchChrome._pouch end + local raw = read_bytes(bp_root() .. "/pouch.rgba") + if not raw or #raw < 64 * 64 * 4 then return nil end + local img = rgba_to_image(raw, 64, 64) + BerryPouchChrome._pouch = img + return img +end + +function BerryPouchChrome.drawBg(x, y, opts) + opts = opts or {} + local img = BerryPouchChrome.loadBg(opts.female) + if img then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x or 0, y or 0) + return true + end + return false +end + +function BerryPouchChrome.drawPouch(x, y, angle) + local img = BerryPouchChrome.loadPouch() + if img then + love.graphics.setColor(1, 1, 1, 1) + if angle and math.abs(angle) > 0.001 then + love.graphics.draw(img, (x or 0) + 32, (y or 0) + 32, angle, 1, 1, 32, 32) + else + love.graphics.draw(img, x or 0, y or 0) + end + return true + end + return false +end + +return BerryPouchChrome diff --git a/src/ui/game3/box_storage_ui.lua b/src/ui/game3/box_storage_ui.lua index e201b89b..99a8a5c0 100644 --- a/src/ui/game3/box_storage_ui.lua +++ b/src/ui/game3/box_storage_ui.lua @@ -709,12 +709,18 @@ function BoxStorageUI.draw() local isHovered = (BoxStorageUI.cursorSlot == s and BoxStorageUI.mode ~= "party_drawer" and not BoxStorageUI.holdingMon) local bounceY = (isHovered and BoxStorageUI.hoverFrame == 1) and -2 or 0 + local f = (isHovered and BoxStorageUI.hoverFrame == 1) and 1 or 0 local sp = Pokemon.speciesOf(mon) local icon = Pokemon.icon(sp) if icon and icon.image then + local q = icon.quads and (icon.quads[f] or icon.quads[0]) love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(icon.image, px, py + bounceY) + if q then + love.graphics.draw(icon.image, q, px, py + bounceY) + else + love.graphics.draw(icon.image, px, py + bounceY) + end end -- Held Item indicator (small yellow dot/diamond) @@ -773,8 +779,13 @@ function BoxStorageUI.draw() local hSp = Pokemon.speciesOf(BoxStorageUI.holdingMon) local hIcon = Pokemon.icon(hSp) if hIcon and hIcon.image then + local q = hIcon.quads and hIcon.quads[0] love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(hIcon.image, curX - 16, curY - 12) + if q then + love.graphics.draw(hIcon.image, q, curX - 16, curY - 12) + else + love.graphics.draw(hIcon.image, curX - 16, curY - 12) + end love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index 01cd5543..0a25fe53 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -72,9 +72,11 @@ FrlgFont.COLOR = { 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] }, + LIGHT = { 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] }, + DARK = { fg = FrlgFont.STDPAL[2], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] }, } -- NPC Text Color Enums matching pokefirered/include/constants/event_objects.h @@ -421,6 +423,9 @@ local function buildRev() ["‘"] = 0xB3, ["’"] = 0xB4, ["'"] = 0xB4, + ["$"] = 0xB7, + ["¥"] = 0xB7, + ["\xC2\xA5"] = 0xB7, } for code, ch in pairs(TextIR.CHARMAP or {}) do if type(ch) == "string" and #ch > 0 and not rev[ch] then @@ -639,6 +644,7 @@ function FrlgFont.advance(glyphId, opts) local w = sw and sw[glyphId] if not w then if glyphId == 0x108 then return 8 end + if glyphId == 0xB7 then return 6 end w = (sw and sw[0]) or 5 end return w @@ -647,6 +653,7 @@ function FrlgFont.advance(glyphId, opts) local w = FrlgFont._widths[glyphId] if not w then if glyphId == 0x108 then return 9 end + if glyphId == 0xB7 then return 7 end w = FrlgFont._widths[0] or 6 end return w diff --git a/src/ui/game3/hall_of_fame.lua b/src/ui/game3/hall_of_fame.lua new file mode 100644 index 00000000..f9e29dae --- /dev/null +++ b/src/ui/game3/hall_of_fame.lua @@ -0,0 +1,244 @@ +-- FRLG Hall of Fame Induction Screen (pret hall_of_fame.c). +-- Displays team members 1..6 (strictly skipping eggs), saves clear status +-- and debut timestamp before yielding to credits. + +local Stack = require("src.ui.game3.stack") +local FrlgFont = require("src.ui.game3.frlg_font") +local Pokemon = require("src.core.game3.pokemon") + +local HallOfFame = {} + +HallOfFame.open = false +HallOfFame._session = nil +HallOfFame._onDone = nil +HallOfFame._mons = {} +HallOfFame._currentIndex = 1 +HallOfFame._phase = "idle" -- "saving", "mon", "congrats", "done" +HallOfFame._timer = 0 + +local FLAG_SYS_GAME_CLEAR = 0x82C -- 2092 + +local function extract_eligible_mons(party) + local eligible = {} + if type(party) ~= "table" then return eligible end + for i = 1, math.min(6, #party) do + local mon = party[i] + if mon and not Pokemon.isEgg(mon) then + table.insert(eligible, mon) + end + end + return eligible +end + +local function commit_clear_and_save(session, eligibleMons) + if not session then return end + + -- 1. Set clear flag + session.flags = session.flags or {} + session.flags[FLAG_SYS_GAME_CLEAR] = true + session.game_cleared = true + session.hasHallOfFameRecords = true + + -- 2. Timestamp debut + local h = tonumber(session.playTimeHours or session.hours) or 0 + local m = tonumber(session.playTimeMinutes or session.minutes) or 0 + local s = tonumber(session.playTimeSeconds or session.seconds) or 0 + session.hofDebutHours = h + session.hofDebutMinutes = m + session.hofDebutSeconds = s + session.hofDebutTime = string.format("%d:%02d:%02d", h, m, s) + + -- 3. Record Hall of Fame team + session.hallOfFameTeams = session.hallOfFameTeams or {} + local teamRecord = {} + for _, mon in ipairs(eligibleMons) do + local sp = mon.species or 1 + local lvl = tonumber(mon.level) or 1 + local nick = mon.nickname or mon.name or Pokemon.name(sp) or "POKéMON" + local tid = tonumber(mon.otId or mon.tid or session.trainerId or 0) + table.insert(teamRecord, { + species = sp, + level = lvl, + nickname = nick, + trainerId = tid, + }) + end + table.insert(session.hallOfFameTeams, teamRecord) + + -- 4. Commit atomic save to disk + local okSave, SaveData = pcall(require, "src.core.game3.save") + if okSave and SaveData and SaveData.save then + pcall(SaveData.save, session) + end +end + +function HallOfFame.start(opts) + opts = opts or {} + local session = opts.session or {} + HallOfFame._session = session + HallOfFame._onDone = opts.onDone + HallOfFame.open = true + HallOfFame._currentIndex = 1 + HallOfFame._timer = 0 + + -- Filter non-egg party members + HallOfFame._mons = extract_eligible_mons(session.party) + + -- Audio + local okAudio, Audio = pcall(require, "src.core.game3.audio") + if okAudio and Audio and Audio.playBGM then + Audio.playBGM(297) -- MUS_HALL_OF_FAME + end + + -- Atomic save + commit_clear_and_save(session, HallOfFame._mons) + + if #HallOfFame._mons > 0 then + HallOfFame._phase = "mon" + else + HallOfFame._phase = "congrats" + end + + Stack.push("hall_of_fame", HallOfFame, { hideBelow = true }) +end + +function HallOfFame.close() + if not HallOfFame.open then return end + HallOfFame.open = false + HallOfFame._phase = "done" + Stack.pop("hall_of_fame") + local cb = HallOfFame._onDone + HallOfFame._onDone = nil + if cb then cb() end +end + +function HallOfFame.isOpen() + return HallOfFame.open +end + +function HallOfFame.getCurrentMon() + if not HallOfFame.open or HallOfFame._phase ~= "mon" then return nil end + return HallOfFame._mons[HallOfFame._currentIndex] +end + +function HallOfFame.advance() + if HallOfFame._phase == "mon" then + if HallOfFame._currentIndex < #HallOfFame._mons then + HallOfFame._currentIndex = HallOfFame._currentIndex + 1 + HallOfFame._timer = 0 + else + HallOfFame._phase = "congrats" + HallOfFame._timer = 0 + end + elseif HallOfFame._phase == "congrats" then + HallOfFame.close() + end +end + +function HallOfFame.handleInput(inp) + if not HallOfFame.open or not inp then return end + if inp:wasPressed("a") or inp:wasPressed("b") or inp:wasPressed("start") then + HallOfFame.advance() + end +end + +function HallOfFame.update(dt) + if not HallOfFame.open then return end + HallOfFame._timer = (HallOfFame._timer or 0) + (dt or 0) + -- Auto-advance after 5 seconds per mon if no button pressed + if HallOfFame._timer >= 5.0 then + HallOfFame.advance() + end +end + +function HallOfFame.draw() + if not HallOfFame.open then return end + local session = HallOfFame._session or {} + + -- Background: deep dark navy / slate tone + love.graphics.setColor(0.08, 0.09, 0.14, 1) + love.graphics.rectangle("fill", 0, 0, 240, 160) + + -- Inner card frame + love.graphics.setColor(0.18, 0.22, 0.32, 1) + love.graphics.rectangle("fill", 8, 8, 224, 144) + love.graphics.setColor(0.12, 0.14, 0.22, 1) + love.graphics.rectangle("fill", 10, 10, 220, 140) + + if HallOfFame._phase == "mon" then + local mon = HallOfFame.getCurrentMon() + if mon then + -- Header + local hdr = string.format("HALL OF FAME No. %d", HallOfFame._currentIndex) + FrlgFont.draw(hdr, 20, 16, { colors = FrlgFont.COLOR.WHITE or FrlgFont.COLOR.NORMAL }) + + -- Mon Sprite Frame + love.graphics.setColor(0.25, 0.30, 0.42, 1) + love.graphics.rectangle("fill", 18, 34, 68, 68) + love.graphics.setColor(0.15, 0.18, 0.28, 1) + love.graphics.rectangle("fill", 20, 36, 64, 64) + + local sp = mon.species or 1 + local frontImg = Pokemon.front and Pokemon.front(sp) + if frontImg then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(frontImg, 20, 36) + end + + -- Mon Stats + local dexNo = Pokemon.nationalDex and Pokemon.nationalDex(sp) or tonumber(sp) or 1 + local monName = mon.nickname or mon.name or Pokemon.name(sp) or "POKéMON" + local lvl = tonumber(mon.level) or 1 + local otId = tonumber(mon.otId or mon.tid or session.trainerId or 0) % 65536 + local otName = tostring(mon.otName or mon.ot or session.name or session.playerName or "RED") + + FrlgFont.draw(string.format("No. %03d", dexNo), 96, 36, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(monName, 96, 50, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("Lv. %d", lvl), 96, 64, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("IDNo. %05d", otId), 96, 78, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("OT/ %s", otName), 96, 92, { colors = FrlgFont.COLOR.NORMAL }) + + -- Moves Section (4 moves) + love.graphics.setColor(0.18, 0.22, 0.32, 1) + love.graphics.rectangle("fill", 18, 108, 204, 36) + + local moves = mon.moves or mon.moveIds or {} + for slot = 1, 4 do + local mv = moves[slot] + local mvName = "-" + if mv then + if type(mv) == "table" then + mvName = mv.name or (mv.id and Pokemon.moveName and Pokemon.moveName(mv.id)) or "-" + elseif type(mv) == "number" or type(mv) == "string" then + mvName = (Pokemon.moveName and Pokemon.moveName(mv)) or tostring(mv) + end + end + local col = (slot - 1) % 2 + local row = math.floor((slot - 1) / 2) + local mx = 24 + col * 100 + local my = 112 + row * 14 + FrlgFont.draw(mvName:upper(), mx, my, { colors = FrlgFont.COLOR.NORMAL }) + end + end + elseif HallOfFame._phase == "congrats" then + -- League Champions Congratulations Screen + FrlgFont.draw("LEAGUE CHAMPION!", 54, 24, { colors = FrlgFont.COLOR.WHITE or FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("CONGRATULATIONS!", 48, 44, { colors = FrlgFont.COLOR.NORMAL }) + + local name = tostring(session.name or session.playerName or "RED") + local rawId = tonumber(session.trainerId or session.id or session.playerTrainerId) or 0 + local idStr = string.format("%05d", rawId % 65536) + + FrlgFont.draw("NAME: " .. name, 32, 72, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("IDNo. " .. idStr, 140, 72, { colors = FrlgFont.COLOR.NORMAL }) + + local h = session.hofDebutHours or tonumber(session.playTimeHours or session.hours) or 0 + local m = session.hofDebutMinutes or tonumber(session.playTimeMinutes or session.minutes) or 0 + local s = session.hofDebutSeconds or tonumber(session.playTimeSeconds or session.seconds) or 0 + FrlgFont.draw(string.format("HOF DEBUT: %d:%02d:%02d", h, m, s), 32, 94, { colors = FrlgFont.COLOR.NORMAL }) + + FrlgFont.draw("PRESS A TO CONTINUE", 52, 126, { colors = FrlgFont.COLOR.NORMAL }) + end +end + +return HallOfFame diff --git a/src/ui/game3/naming.lua b/src/ui/game3/naming.lua index 9decc5ad..7c369cb9 100644 --- a/src/ui/game3/naming.lua +++ b/src/ui/game3/naming.lua @@ -259,9 +259,14 @@ local function drawPlayerIcon(st) end if entry and entry.image then local iw = entry.w or entry.image:getWidth() - local ih = entry.h or entry.image:getHeight() + local ih = entry.h or (entry.quads and entry.h) or entry.image:getHeight() local sc = math.min(L.iconW / iw, L.iconH / ih) - love.graphics.draw(entry.image, L.iconCX, L.iconCY, 0, sc, sc, iw / 2, ih / 2) + local q = entry.quads and entry.quads[0] + if q then + love.graphics.draw(entry.image, q, L.iconCX, L.iconCY, 0, sc, sc, iw / 2, ih / 2) + else + love.graphics.draw(entry.image, L.iconCX, L.iconCY, 0, sc, sc, iw / 2, ih / 2) + end return end end diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 0367df18..ccfbcaba 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -419,6 +419,7 @@ end function PartyMenu.close() PartyMenu.open = false + PartyMenu.mode = "list" destroy_party_oam() Stack.pop("party") local cb = PartyMenu._onClose @@ -471,10 +472,9 @@ function PartyMenu.dismissMessage() local cb = PartyMenu._onMessageDismiss PartyMenu._messageText = nil PartyMenu._onMessageDismiss = nil + PartyMenu.mode = "list" if cb then cb() - else - PartyMenu.mode = "list" end end end @@ -1539,8 +1539,8 @@ function PartyMenu.draw() FrlgFont.draw(tostring(mv), popX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) end elseif PartyMenu.mode == "item_action" then - 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 }) + Window.stdFrame(Window.template(1, 17, 18, 2)) + FrlgFont.draw("Do what with an item?", 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) local actCount = #PartyMenu.ITEM_ACTIONS local popW = 7 @@ -1556,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(1, 17, 16, 2)) - FrlgFont.draw("Do what with this\nPOKéMON?", 1 * 8 + 2, 17 * 8 + 1, { linePitch = 15, colors = FrlgFont.COLOR.NORMAL }) + Window.stdFrame(Window.template(1, 17, 17, 2)) + FrlgFont.draw("Do what with this PKMN?", 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) local actCount = #PartyMenu.ACTIONS local popW = 10 @@ -1589,9 +1589,6 @@ function PartyMenu.draw() 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/pc_chrome.lua b/src/ui/game3/pc_chrome.lua index ee10bc70..66b46a98 100644 --- a/src/ui/game3/pc_chrome.lua +++ b/src/ui/game3/pc_chrome.lua @@ -376,19 +376,26 @@ function PcChrome.drawPartyDrawer(party, partyCursor, hoverFrame, holdingSource) end -- 3. Party Pokémon Animated Mini-Icons - -- Slot 1 (Lead): centered at (104, 64) -> (88, 48) + -- Slot 1 (Lead): pret Center (104, 64) -> Top-Left (88, 48) local isLeadPickedUp = (holdingSource and holdingSource.loc == "party" and holdingSource.slot == 1) local leadMon = (not isLeadPickedUp) and party[1] if leadMon then local sp = Pokemon.speciesOf(leadMon) local icon = Pokemon.icon(sp) if icon and icon.image then - local bounce = (partyCursor == 1 and (hoverFrame % 2 == 1)) and -2 or 0 - love.graphics.draw(icon.image, 88, 48 + bounce) + local isHovered = (partyCursor == 1) + local bounce = (isHovered and (hoverFrame % 2 == 1)) and -2 or 0 + local f = (isHovered and (hoverFrame % 2 == 1)) and 1 or 0 + local q = icon.quads and (icon.quads[f] or icon.quads[0]) + if q then + love.graphics.draw(icon.image, q, 88, 48 + bounce) + else + love.graphics.draw(icon.image, 88, 48 + bounce) + end end end - -- Slots 2..6: centered at (152, 16 + (p - 2) * 24) -> (136, (p - 2) * 24) + -- Slots 2..6: pret Center (152, 16 + (p - 2) * 24) -> Top-Left (136, (p - 2) * 24) for p = 2, 6 do local isPickedUp = (holdingSource and holdingSource.loc == "party" and holdingSource.slot == p) local pMon = (not isPickedUp) and party[p] @@ -396,8 +403,16 @@ function PcChrome.drawPartyDrawer(party, partyCursor, hoverFrame, holdingSource) local sp = Pokemon.speciesOf(pMon) local icon = Pokemon.icon(sp) if icon and icon.image then - local bounce = (partyCursor == p and (hoverFrame % 2 == 1)) and -2 or 0 - love.graphics.draw(icon.image, 136, (p - 2) * 24 + bounce) + local isHovered = (partyCursor == p) + local bounce = (isHovered and (hoverFrame % 2 == 1)) and -2 or 0 + local f = (isHovered and (hoverFrame % 2 == 1)) and 1 or 0 + local q = icon.quads and (icon.quads[f] or icon.quads[0]) + local iy = (p - 2) * 24 + if q then + love.graphics.draw(icon.image, q, 136, iy + bounce) + else + love.graphics.draw(icon.image, 136, iy + bounce) + end end end end diff --git a/src/ui/game3/pokedex.lua b/src/ui/game3/pokedex.lua index de836942..db10cb15 100644 --- a/src/ui/game3/pokedex.lua +++ b/src/ui/game3/pokedex.lua @@ -964,8 +964,13 @@ local function draw_data_screen() -- 3. Top Left: Mon Icon (32x32) at (14, 20) local icon = Pokemon.icon and Pokemon.icon(sp) if icon and icon.image then + local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(icon.image, 14, 20) + if q then + love.graphics.draw(icon.image, q, 14, 20) + else + love.graphics.draw(icon.image, 14, 20) + end end -- Top Left: Dex No & Species Name @@ -1145,8 +1150,13 @@ local function draw_area_screen() -- Left Column: Mon Icon & Types & Name local icon = Pokemon.icon and Pokemon.icon(sp) if icon and icon.image then + local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(icon.image, 14, 28) + if q then + love.graphics.draw(icon.image, q, 14, 28) + else + love.graphics.draw(icon.image, 14, 28) + end end FrlgFont.draw(name, 48, 34, { color = { 0.1, 0.1, 0.1, 1 } }) diff --git a/src/ui/game3/region_map.lua b/src/ui/game3/region_map.lua index ca9420a1..77c6b8e0 100644 --- a/src/ui/game3/region_map.lua +++ b/src/ui/game3/region_map.lua @@ -1,57 +1,100 @@ --- Sevii region map UI — page art + landmark cursor (pret region_map field). +-- FRLG Kanto & Sevii Region Map UI (pret region_map.c). +-- Interactive Town Map screen showing Kanto map, player location marker, +-- landmark selection cursor, and authentic top/bottom header bars. -local RegionData = require("src.import.gba.region_map_sevii") local Stack = require("src.ui.game3.stack") local Window = require("src.ui.game3.window") local Display = require("src.core.game3.display") +local FrlgFont = require("src.ui.game3.frlg_font") +local RegionExtract = require("src.import.gba.region_map_extract") local RegionMap = {} RegionMap.open = false -RegionMap.page = 1 -RegionMap.cursor = 1 +RegionMap.cursorX = 4 +RegionMap.cursorY = 11 +RegionMap.playerX = 4 +RegionMap.playerY = 11 +RegionMap.playerGender = 0 -- 0: Red (male), 1: Leaf (female) RegionMap._images = {} +RegionMap._session = nil +RegionMap._onClose = nil -local function load_page_image(page) - if not page or not page.image then return nil end - local key = page.image - if RegionMap._images[key] ~= nil then - return RegionMap._images[key] or nil +local MAP_OFFSET_X = 36 +local MAP_OFFSET_Y = 24 +local CELL_SIZE = 8 + +local function try_load_image(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local ok, img = pcall(love.graphics.newImage, path) + if ok and img then + if img.setFilter then img:setFilter("nearest", "nearest") end + return img + end + return nil +end + +local function get_map_image() + if RegionMap._images["kanto_map"] ~= nil then + return RegionMap._images["kanto_map"] or nil end local candidates = { - "mods/Kanto-Reforged/" .. key, - key, - "sevii/" .. key, - "mods/Kanto-Reforged/sevii/gba/chrome/menus/region_map/" .. (page.id or "page") .. ".png", + "data/generated/gba/region_map/kanto_map.png", + "pokefirered/graphics/region_map/region_map.png", + "assets/generated/region_map/kanto_map.png", } - local okA, Assets = pcall(require, "src.render.Assets") - for _, path in ipairs(candidates) do - if okA and Assets and Assets.image then - local ok, img = pcall(Assets.image, path) - if ok and img then - if img.setFilter then img:setFilter("nearest", "nearest") end - RegionMap._images[key] = img - return img - end - end - local ok, img = pcall(love.graphics.newImage, path) - if ok and img then - if img.setFilter then img:setFilter("nearest", "nearest") end - RegionMap._images[key] = img + for _, p in ipairs(candidates) do + local img = try_load_image(p) + if img then + RegionMap._images["kanto_map"] = img return img end end - RegionMap._images[key] = false + RegionMap._images["kanto_map"] = false return nil end +local function get_cursor_image() + if RegionMap._images["cursor"] ~= nil then + return RegionMap._images["cursor"] or nil + end + local img = try_load_image("pokefirered/graphics/region_map/cursor.png") + or try_load_image("data/generated/gba/region_map/cursor.png") + RegionMap._images["cursor"] = img or false + return img +end + +local function get_player_image(female) + local key = female and "player_leaf" or "player_red" + if RegionMap._images[key] ~= nil then + return RegionMap._images[key] or nil + end + local path = female and "pokefirered/graphics/region_map/player_icon_leaf.png" + or "pokefirered/graphics/region_map/player_icon_red.png" + local img = try_load_image(path) or try_load_image("data/generated/gba/region_map/" .. key .. ".png") + RegionMap._images[key] = img or false + return img +end + function RegionMap.show(opts) opts = opts or {} RegionMap.open = true - RegionMap.page = 1 - RegionMap.cursor = 1 - RegionMap._onClose = opts.onClose RegionMap._session = opts.session + RegionMap._onClose = opts.onClose + + local session = opts.session + local mapId = session and session.map + local mapSec = session and session.mapSec + local loc = RegionExtract.resolveLocation(mapId, mapSec) + + RegionMap.playerX = loc.x + RegionMap.playerY = loc.y + RegionMap.cursorX = loc.x + RegionMap.cursorY = loc.y + + local gender = session and (session.gender or session.playerGender) + RegionMap.playerGender = (gender == 1 or gender == "female") and 1 or 0 + Stack.push("region_map", RegionMap, { hideBelow = true }) end @@ -63,82 +106,158 @@ function RegionMap.close() if cb then cb() end end -function RegionMap.pageCount() - return #(RegionData.PAGES or {}) -end - -function RegionMap.currentPage() - return RegionData.PAGES[RegionMap.page] -end - -function RegionMap.togglePage(delta) - local n = RegionMap.pageCount() - if n < 1 then return end - RegionMap.page = ((RegionMap.page - 1 + (delta or 1)) % n) + 1 - RegionMap.cursor = 1 -end - -function RegionMap.moveCursor(delta) - local page = RegionMap.currentPage() - local marks = page and page.landmarks or {} - local n = #marks - if n < 1 then return end - RegionMap.cursor = ((RegionMap.cursor - 1 + delta) % n) + 1 -end - -function RegionMap.currentLandmark() - local page = RegionMap.currentPage() - local marks = page and page.landmarks or {} - return marks[RegionMap.cursor] -end - function RegionMap.isOpen() return RegionMap.open end -function RegionMap.draw() - if not RegionMap.open then return end - local page = RegionMap.currentPage() - local img = load_page_image(page) +function RegionMap.currentLocationName() + local row = RegionExtract.KANTO_GRID[RegionMap.cursorY] + local sec = row and row[RegionMap.cursorX] + if sec and RegionExtract.SECTION_NAMES[sec] then + return RegionExtract.SECTION_NAMES[sec] + end + return "KANTO REGION" +end - -- Full-screen map layer. - love.graphics.setColor(0.10, 0.35, 0.55, 1) - love.graphics.rectangle("fill", 0, 0, Display.W, Display.H) - love.graphics.setColor(1, 1, 1, 1) - if img then - local iw, ih = img:getDimensions() - local sx = Display.W / iw - local sy = Display.H / ih - love.graphics.draw(img, 0, 0, 0, sx, sy) - else - -- Procedural placeholder matching page landmarks. - Window.print(page and page.label or "SEVII", 1, 1) - local marks = page and page.landmarks or {} - for i, lm in ipairs(marks) do - local px = lm.px or 40 - local py = lm.py or 40 - if i == RegionMap.cursor then - love.graphics.setColor(1, 0.2, 0.2, 1) - else - love.graphics.setColor(1, 1, 0.3, 1) - end - love.graphics.rectangle("fill", px - 2, py - 2, 5, 5) - end - love.graphics.setColor(1, 1, 1, 1) +function RegionMap.handleInput(input) + local function se(id) + pcall(function() require("src.core.game3.audio").playSe(id) end) end - local lm = RegionMap.currentLandmark() - -- Name plate (bottom). - Window.stdFrame(Window.template(2, 16, 26, 2)) - Window.print(lm and lm.name or (page and page.label) or "", 3, 16) - if img then - -- Cursor crosshair on landmark. - if lm and lm.px and lm.py then - love.graphics.setColor(1, 0.15, 0.15, 1) - love.graphics.rectangle("line", lm.px - 4, lm.py - 4, 9, 9) - love.graphics.setColor(1, 1, 1, 1) + if input:wasPressed("b") or input:wasPressed("start") or input:wasPressed("select") then + se(9) + RegionMap.close() + return + end + + local moved = false + if input:wasPressed("left") then + if RegionMap.cursorX > 0 then + RegionMap.cursorX = RegionMap.cursorX - 1 + moved = true end + elseif input:wasPressed("right") then + if RegionMap.cursorX < RegionExtract.MAP_WIDTH - 1 then + RegionMap.cursorX = RegionMap.cursorX + 1 + moved = true + end + elseif input:wasPressed("up") then + if RegionMap.cursorY > 0 then + RegionMap.cursorY = RegionMap.cursorY - 1 + moved = true + end + elseif input:wasPressed("down") then + if RegionMap.cursorY < RegionExtract.MAP_HEIGHT - 1 then + RegionMap.cursorY = RegionMap.cursorY + 1 + moved = true + end + end + + if moved then + se(5) end end +function RegionMap.draw() + if not RegionMap.open then return end + if not (love and love.graphics) then return end + + -- 1. Base Sea Backdrop (#5888A8 / authentic blue) + love.graphics.setColor(0.35, 0.53, 0.66, 1.0) + love.graphics.rectangle("fill", 0, 0, Display.W, Display.H) + love.graphics.setColor(1, 1, 1, 1) + + -- 2. Kanto Map Backdrop + local mapImg = get_map_image() + if mapImg then + love.graphics.draw(mapImg, 0, 0) + else + -- Procedural Kanto Geography & Route network + -- Landmass Fill + love.graphics.setColor(0.55, 0.78, 0.45, 1.0) -- Land green + love.graphics.rectangle("fill", MAP_OFFSET_X, MAP_OFFSET_Y, RegionExtract.MAP_WIDTH * CELL_SIZE, RegionExtract.MAP_HEIGHT * CELL_SIZE) + + -- Draw Routes and Cities from Grid + for y = 0, RegionExtract.MAP_HEIGHT - 1 do + local row = RegionExtract.KANTO_GRID[y] + if row then + for x = 0, RegionExtract.MAP_WIDTH - 1 do + local sec = row[x] + if sec then + local px = MAP_OFFSET_X + x * CELL_SIZE + local py = MAP_OFFSET_Y + y * CELL_SIZE + if sec:find("CITY", 1, true) or sec:find("TOWN", 1, true) or sec:find("PLATEAU", 1, true) then + -- City Node + love.graphics.setColor(0.85, 0.20, 0.20, 1.0) -- Red city square + love.graphics.rectangle("fill", px + 1, py + 1, 6, 6) + love.graphics.setColor(0.2, 0.2, 0.2, 1.0) + love.graphics.rectangle("line", px + 1, py + 1, 6, 6) + elseif sec:find("ROUTE", 1, true) then + -- Route Path + love.graphics.setColor(0.70, 0.65, 0.50, 1.0) -- Path beige + love.graphics.rectangle("fill", px + 2, py + 2, 4, 4) + else + -- Cave / Dungeon Node + love.graphics.setColor(0.40, 0.35, 0.30, 1.0) -- Brown cave + love.graphics.rectangle("fill", px + 2, py + 2, 4, 4) + end + end + end + end + end + end + + -- 3. Player Head Marker + local female = (RegionMap.playerGender == 1) + local playerImg = get_player_image(female) + local pPx = MAP_OFFSET_X + RegionMap.playerX * CELL_SIZE + local pPy = MAP_OFFSET_Y + RegionMap.playerY * CELL_SIZE + if playerImg then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(playerImg, pPx - 4, pPy - 4) + else + -- Fallback Head icon + love.graphics.setColor(female and 0.9 or 0.2, 0.2, female and 0.4 or 0.9, 1) + love.graphics.circle("fill", pPx + 4, pPy + 4, 5) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.circle("line", pPx + 4, pPy + 4, 5) + end + + -- 4. Target Selection Cursor + local cursorImg = get_cursor_image() + local cPx = MAP_OFFSET_X + RegionMap.cursorX * CELL_SIZE + local cPy = MAP_OFFSET_Y + RegionMap.cursorY * CELL_SIZE + if cursorImg then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(cursorImg, cPx - 4, cPy - 4) + else + -- Authentic animated / blinking cursor box + love.graphics.setColor(1, 0.1, 0.1, 1) + love.graphics.rectangle("line", cPx - 1, cPy - 1, 10, 10) + love.graphics.setColor(1, 1, 1, 1) + end + + -- 5. Top Landmark Name Banner (y=0..20, dark header frame with white font) + love.graphics.setColor(0.12, 0.22, 0.18, 0.92) + love.graphics.rectangle("fill", 0, 0, Display.W, 20) + love.graphics.setColor(0.40, 0.60, 0.50, 1.0) + love.graphics.rectangle("fill", 0, 20, Display.W, 2) + love.graphics.setColor(1, 1, 1, 1) + + local locName = RegionMap.currentLocationName() + local tw = FrlgFont.measure(locName) + local tx = math.floor((Display.W - tw) / 2) + FrlgFont.draw(locName, tx, 3, { colors = FrlgFont.COLOR.WHITE }) + + -- 6. Bottom Control Hint Bar (y=144..160) + love.graphics.setColor(0.12, 0.22, 0.18, 0.92) + love.graphics.rectangle("fill", 0, 144, Display.W, 16) + love.graphics.setColor(0.40, 0.60, 0.50, 1.0) + love.graphics.rectangle("fill", 0, 143, Display.W, 1) + love.graphics.setColor(1, 1, 1, 1) + + FrlgFont.draw("D-PAD: MOVE", 8, 146, { colors = FrlgFont.COLOR.WHITE, small = true }) + FrlgFont.draw("B BUTTON: CANCEL", 140, 146, { colors = FrlgFont.COLOR.WHITE, small = true }) +end + return RegionMap diff --git a/src/ui/game3/release_seq.lua b/src/ui/game3/release_seq.lua index f553a063..a0da349b 100644 --- a/src/ui/game3/release_seq.lua +++ b/src/ui/game3/release_seq.lua @@ -126,8 +126,13 @@ function ReleaseSeq.draw() local icon = ReleaseSeq.mon and Pokemon.icon(Pokemon.speciesOf(ReleaseSeq.mon)) if icon and icon.image then + local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, alpha) - love.graphics.draw(icon.image, curX, curY, 0, scale, scale, 16, 16) + if q then + love.graphics.draw(icon.image, q, curX, curY, 0, scale, scale, 16, 16) + else + love.graphics.draw(icon.image, curX, curY, 0, scale, scale, 16, 16) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/game3/tm_case.lua b/src/ui/game3/tm_case.lua new file mode 100644 index 00000000..394e23ff --- /dev/null +++ b/src/ui/game3/tm_case.lua @@ -0,0 +1,433 @@ +-- FRLG TM Case Sub-Container UI (item_menu.c / tm_case.c). +-- 1:1 layout matching pret pokefirered: +-- - WIN_TITLE: (0, 1, 10, 2) -> (0, 8, 80, 16), centered title "TM CASE" +-- - WIN_LIST: (10, 1, 19, 10) -> (80, 8, 152, 80), 5 visible rows on dashed lines +-- - WIN_DESCRIPTION: (12, 12, 18, 8) -> (96, 96, 144, 64), text at (98, 100) +-- - WIN_MOVE_INFO_LABELS: (1, 13, 5, 6) -> (8, 104, 40, 48), TYPE / POWER / ACCURACY / PP +-- - WIN_MOVE_INFO: (7, 13, 5, 6) -> (56, 104, 40, 48), Type Badge & right-aligned values +-- - Disc Sprite: centered at (41, 46) -> top-left at (25, 30) +-- - WIN_USE_GIVE_EXIT: (22, 13, 7, 6) -> (176, 104, 56, 48) + +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local ItemsData = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local Pokemon = require("src.core.game3.pokemon") +local SummaryData = require("src.core.game3.summary_data") +local SummaryChrome = require("src.ui.game3.summary_chrome") + +local TmCase = {} + +TmCase.open = false +TmCase.cursor = 1 +TmCase.scroll = 0 +TmCase.mode = "list" -- "list" | "action" | "message" +TmCase.actionCursor = 1 +TmCase.messageText = nil + +local VISIBLE = 5 -- 1:1 pret sTMCaseDynamicResources->maxTMsShown = 5 +local ACTIONS = { "USE", "GIVE", "EXIT" } + +-- Disc Type Palette mapping for disc render fallback +local TYPE_DISC_COLORS = { + NORMAL = { 0.65, 0.65, 0.55 }, + FIGHTING = { 0.75, 0.20, 0.15 }, + FLYING = { 0.60, 0.70, 0.90 }, + POISON = { 0.60, 0.25, 0.60 }, + GROUND = { 0.85, 0.75, 0.40 }, + ROCK = { 0.70, 0.60, 0.25 }, + BUG = { 0.60, 0.70, 0.15 }, + GHOST = { 0.45, 0.35, 0.60 }, + STEEL = { 0.70, 0.70, 0.80 }, + FIRE = { 0.95, 0.50, 0.20 }, + WATER = { 0.35, 0.55, 0.90 }, + GRASS = { 0.45, 0.80, 0.30 }, + ELECTRIC = { 0.95, 0.80, 0.20 }, + PSYCHIC = { 0.95, 0.35, 0.55 }, + ICE = { 0.55, 0.85, 0.85 }, + DRAGON = { 0.45, 0.20, 0.95 }, + DARK = { 0.40, 0.30, 0.25 }, +} + +local function se(id) + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio and Audio.playSe then Audio.playSe(id) end + end) +end + +function TmCase.isOpen() + return TmCase.open +end + +function TmCase.list() + local bag = TmCase._bag + if not bag then return {} end + local rows = Bag.listPocket(bag, "TM_CASE") + return rows or {} +end + +local function get_total_count() + local rows = TmCase.list() + return #rows + 1 -- +1 for Cancel +end + +local function clamp_cursor() + local rows = TmCase.list() + local total = #rows + 1 + if total < 1 then + TmCase.cursor = 1 + TmCase.scroll = 0 + return rows + end + if TmCase.cursor > total then TmCase.cursor = total end + if TmCase.cursor < 1 then TmCase.cursor = 1 end + if TmCase.cursor <= TmCase.scroll then + TmCase.scroll = TmCase.cursor - 1 + end + if TmCase.cursor > TmCase.scroll + VISIBLE then + TmCase.scroll = TmCase.cursor - VISIBLE + end + if TmCase.scroll < 0 then TmCase.scroll = 0 end + return rows +end + +function TmCase.show(session, bag, opts) + opts = opts or {} + TmCase.open = true + TmCase._session = session or opts.session + TmCase._bag = bag or opts.bag or (session and session.bag) + TmCase._onClose = opts.onClose + TmCase.cursor = opts.cursor or 1 + TmCase.scroll = opts.scroll or 0 + TmCase.mode = "list" + TmCase.actionCursor = 1 + TmCase.messageText = nil + clamp_cursor() + Stack.push("tm_case", TmCase, { hideBelow = true }) +end + +function TmCase.close() + TmCase.open = false + Stack.pop("tm_case") + local cb = TmCase._onClose + TmCase._onClose = nil + if cb then cb() end +end + +function TmCase.handleInput(input) + if TmCase.mode == "message" then + if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then + se(5) + TmCase.mode = "list" + TmCase.messageText = nil + clamp_cursor() + end + return + end + + if TmCase.mode == "action" then + if input:wasPressed("up") then + TmCase.actionCursor = ((TmCase.actionCursor - 2) % #ACTIONS) + 1 + se(5) + elseif input:wasPressed("down") then + TmCase.actionCursor = (TmCase.actionCursor % #ACTIONS) + 1 + se(5) + elseif input:wasPressed("a") then + se(5) + local act = ACTIONS[TmCase.actionCursor] + local rows = clamp_cursor() + local row = rows[TmCase.cursor] + if act == "EXIT" or not row then + TmCase.mode = "list" + elseif act == "GIVE" then + TmCase.mode = "message" + TmCase.messageText = "This item can't be held." + elseif act == "USE" then + local party = (TmCase._session and TmCase._session.party) or {} + if #party == 0 then + TmCase.mode = "message" + TmCase.messageText = "There is no POKéMON." + else + local PartyMenu = require("src.ui.game3.party_menu") + PartyMenu.show(party, TmCase._session and TmCase._session.moveOverlay, { + session = TmCase._session, + bag = TmCase._bag, + item = row.id, + mode = "use", + onClose = function() + TmCase.mode = "list" + clamp_cursor() + end, + }) + end + end + elseif input:wasPressed("b") then + se(9) + TmCase.mode = "list" + end + return + end + + -- List mode navigation + local rows = clamp_cursor() + local total = #rows + 1 + + if input:wasPressed("up") then + if total > 0 then + TmCase.cursor = ((TmCase.cursor - 2) % total) + 1 + clamp_cursor() + se(5) + end + elseif input:wasPressed("down") then + if total > 0 then + TmCase.cursor = (TmCase.cursor % total) + 1 + clamp_cursor() + se(5) + end + elseif input:wasPressed("left") or input:wasPressed("l") then + if total > 0 then + TmCase.cursor = math.max(1, TmCase.cursor - VISIBLE) + clamp_cursor() + se(5) + end + elseif input:wasPressed("right") or input:wasPressed("r") then + if total > 0 then + TmCase.cursor = math.min(total, TmCase.cursor + VISIBLE) + clamp_cursor() + se(5) + end + elseif input:wasPressed("a") then + if TmCase.cursor == total then + -- Clicked CANCEL + se(9) + TmCase.close() + else + local row = rows[TmCase.cursor] + if row then + TmCase.mode = "action" + TmCase.actionCursor = 1 + se(5) + end + end + elseif input:wasPressed("b") or input:wasPressed("start") then + se(9) + TmCase.close() + end +end + +local function draw_disc_fallback(cx, cy, moveType) + if not (love and love.graphics) then return end + local col = TYPE_DISC_COLORS[moveType or "NORMAL"] or TYPE_DISC_COLORS.NORMAL + + -- Outer disc ring (32x32 centered at cx, cy) + love.graphics.setColor(col[1] * 0.7, col[2] * 0.7, col[3] * 0.7, 1) + love.graphics.circle("fill", cx, cy, 16) + love.graphics.setColor(col[1], col[2], col[3], 1) + love.graphics.circle("fill", cx, cy, 14) + + -- Metallic shine arc + love.graphics.setColor(1, 1, 1, 0.4) + love.graphics.arc("fill", cx, cy, 14, -math.pi * 0.7, -math.pi * 0.2) + + -- Center spindle hole + love.graphics.setColor(0.15, 0.15, 0.15, 1) + love.graphics.circle("fill", cx, cy, 5) + love.graphics.setColor(0.3, 0.3, 0.3, 1) + love.graphics.circle("line", cx, cy, 5) +end + +function TmCase.draw() + if not TmCase.open then return end + local rows = clamp_cursor() + local total = #rows + 1 + local isCancel = (TmCase.cursor == total) + local sel = not isCancel and rows[TmCase.cursor] or nil + + local okC, TmCaseChrome = pcall(require, "src.ui.game3.tm_case_chrome") + local hasChrome = okC and TmCaseChrome and TmCaseChrome.ready and TmCaseChrome.ready() + local isFemale = false + local session = TmCase._session + if session and (session.gender == 1 or session.gender == "female" or session.playerGender == 1) then + isFemale = true + end + + -- 1. Background (240x160) - BG2 Base + if hasChrome then + TmCaseChrome.drawBg(0, 0, { female = isFemale }) + else + love.graphics.setColor(0.18, 0.42, 0.58, 1) + love.graphics.rectangle("fill", 0, 0, 240, 160) + Window.stdFrame(Window.template(1, 4, 11, 15)) + Window.stdFrame(Window.template(13, 1, 16, 18)) + end + + -- 2. Disc Sprite (in between BG2 and BG1) + if sel then + local moveId = Pokemon.moveFromTmItem(sel.id) + local moveRow = Pokemon.battleMove(moveId) or {} + local moveType = tostring(moveRow.type or "NORMAL"):upper() + local typeIdx = SummaryChrome.TYPE_NAMES and SummaryChrome.TYPE_NAMES[moveType] or 0 + local isHm = ItemsData.isHm(sel.id) + local tmNum = ItemsData.tmNumber(sel.id) or 1 + local tmIdx = isHm and (tmNum - 1) or (tmNum - 1 + 8) + + -- 1:1 dynamic rack positioning formula from pokefirered SetDiscSpritePosition: + local cx = 41 - math.floor((14 * tmIdx) / 58) + local cy = 46 + math.floor((8 * tmIdx) / 58) + + local drawn = hasChrome and TmCaseChrome.drawDisc(typeIdx, cx - 16, cy - 16, isHm) + if not drawn then + draw_disc_fallback(cx, cy, moveType) + end + end + + -- 3. Pocket Cover Overlay (BG1 Priority 0 over Disc Sprite) + if hasChrome then + TmCaseChrome.drawCover(0, 0, { female = isFemale }) + end + + -- 4. Header Title: "TM CASE" (WIN_TITLE: 0, 1, 10, 2 -> 72px center at y=9) + local title = "TM CASE" + local tw = FrlgFont.measure(title) + local tx = math.floor((72 - tw) / 2) + 4 + FrlgFont.draw(title, tx, 9, { colors = FrlgFont.COLOR.LIGHT }) + + + -- 4. Left Pane: Move Details (WIN_MOVE_INFO_LABELS & WIN_MOVE_INFO: y=104..152) + -- Row 0: TYPE (y = 104) + FrlgFont.draw("TYPE", 8, 104, { colors = FrlgFont.COLOR.DARK_GRAY }) + -- Row 1: POWER (y = 116) + FrlgFont.draw("POWER", 8, 116, { colors = FrlgFont.COLOR.DARK_GRAY }) + -- Row 2: ACCURACY (y = 128) + FrlgFont.draw("ACCURACY", 8, 128, { colors = FrlgFont.COLOR.DARK_GRAY }) + -- Row 3: PP (y = 140) + FrlgFont.draw("PP", 8, 140, { colors = FrlgFont.COLOR.DARK_GRAY }) + + if sel then + local moveId = Pokemon.moveFromTmItem(sel.id) + local moveRow = Pokemon.battleMove(moveId) or {} + local moveType = tostring(moveRow.type or "NORMAL"):upper() + local power = tonumber(moveRow.power) or 0 + local accuracy = tonumber(moveRow.accuracy) or 0 + local pp = tonumber(moveRow.pp) or 0 + + -- Type Badge (32x12 at x=44, y=104) + SummaryChrome.drawTypeBadge(moveType, 44, 104) + + -- Values (x=52..68, right-aligned) + local powStr = power >= 2 and string.format("%3d", power) or "---" + local accStr = accuracy > 0 and string.format("%3d", accuracy) or "---" + local ppStr = pp > 0 and string.format("%3d", pp) or "---" + + FrlgFont.draw(powStr, 56, 116, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(accStr, 56, 128, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(ppStr, 56, 140, { colors = FrlgFont.COLOR.NORMAL }) + else + -- Cancel / Empty selected + FrlgFont.draw("---", 56, 104, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("---", 56, 116, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("---", 56, 128, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("---", 56, 140, { colors = FrlgFont.COLOR.NORMAL }) + end + + -- 5. Right Pane: List Menu (WIN_LIST: 10, 1, 19, 10 -> x=80, y=8, 5 visible rows) + if TmCase.scroll > 0 then + FrlgFont.draw("▲", 160, 4, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + if TmCase.scroll + VISIBLE < total then + FrlgFont.draw("▼", 160, 88, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + + for i = 1, VISIBLE do + local idx = TmCase.scroll + i + if idx > total then break end + local y = 10 + (i - 1) * 16 + + -- Selector Cursor + if idx == TmCase.cursor and TmCase.mode == "list" then + Window.cursorPx(84, y) + end + + if idx <= #rows then + local r = rows[idx] + local tmNum = ItemsData.tmNumber(r.id) + local isHm = ItemsData.isHm(r.id) + local mId = Pokemon.moveFromTmItem(r.id) + local mName = Pokemon.moveName(mId) or r.name or "MOVE" + + if isHm then + -- HM icon + HM number + local okHm = hasChrome and TmCaseChrome.drawHmIcon(92, y + 1) + if not okHm then + FrlgFont.draw("HM", 92, y, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + FrlgFont.draw(string.format("%02d", tmNum or 0), 108, y, { colors = FrlgFont.COLOR.DARK_GRAY }) + else + -- TM number + FrlgFont.draw(string.format("%02d", tmNum or 0), 96, y, { colors = FrlgFont.COLOR.DARK_GRAY }) + end + + -- Move Name + FrlgFont.draw(mName, 122, y, { maxWidth = 76, colors = FrlgFont.COLOR.NORMAL }) + + -- Quantity (TMs only) + if not isHm then + FrlgFont.draw(string.format("×%2d", r.qty or 1), 206, y, { colors = FrlgFont.COLOR.NORMAL }) + end + else + -- CANCEL Row + FrlgFont.draw("CANCEL", 92, y, { colors = FrlgFont.COLOR.NORMAL }) + end + end + + -- 6. Bottom Description Pane (WIN_DESCRIPTION: 12, 12, 18, 8 -> text at 98, 100) + if TmCase.mode ~= "action" then + local descText + if isCancel then + descText = "The TM CASE will be\nput away." + elseif sel then + local moveId = Pokemon.moveFromTmItem(sel.id) + local moveName = Pokemon.moveName(moveId) or "---" + descText = SummaryData.moveDescription(moveId, moveName) + if not descText or descText == "---" then + descText = sel.description or ItemsData.description(sel.id) + end + end + if descText then + local wrapped = FrlgFont.wrap(descText, 136) + FrlgFont.draw(wrapped, 98, 100, { maxWidth = 136, linePitch = 14, colors = FrlgFont.COLOR.LIGHT }) + end + end + + -- 7. Action Pop-up Menu (WIN_USE_GIVE_EXIT: 22, 13, 7, 6 -> 176, 104, 56, 48) + if TmCase.mode == "action" and sel then + -- Bottom left prompt window (WIN_SELECTED_MSG: 5, 15, 15, 4 -> 40, 120, 120, 32) + Window.stdFrame(Window.template(5, 15, 15, 4)) + local tmLabel = sel.name or ItemsData.displayName(sel.id) or "TM" + FrlgFont.draw(tmLabel .. " is\nselected.", 44, 122, { maxWidth = 112, linePitch = 14, colors = FrlgFont.COLOR.NORMAL }) + + local popX = 22 + local popY = 13 + local popW = 7 + local popH = 6 + Window.stdFrame(Window.template(popX, popY, popW, popH)) + for i, act in ipairs(ACTIONS) do + local rowY = (popY * 8) + (i - 1) * 16 + 2 + if i == TmCase.actionCursor then + Window.cursorPx(popX * 8 + 1, rowY) + end + FrlgFont.draw(act, popX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) + end + end + + -- 8. Message Modal + if TmCase.mode == "message" and TmCase.messageText then + Window.stdFrame(Window.template(2, 15, 26, 4)) + local wrapped = FrlgFont.wrap(TmCase.messageText, 192) + FrlgFont.draw(wrapped, 20, 122, { maxWidth = 192, linePitch = 14, colors = FrlgFont.COLOR.NORMAL }) + end +end + +return TmCase diff --git a/src/ui/game3/tm_case_chrome.lua b/src/ui/game3/tm_case_chrome.lua new file mode 100644 index 00000000..768cfbbe --- /dev/null +++ b/src/ui/game3/tm_case_chrome.lua @@ -0,0 +1,199 @@ +-- TM Case chrome loader and renderer from CacheFS (items/tm_case/). + +local Extract = require("src.import.gba.extract_island1") +local TmCaseExtract = require("src.import.gba.tm_case_extract") + +local TmCaseChrome = {} + +TmCaseChrome._cache = nil +TmCaseChrome._bgMale = nil +TmCaseChrome._bgFemale = nil +TmCaseChrome._coverMale = nil +TmCaseChrome._coverFemale = nil +TmCaseChrome._discs = {} -- typeIdx (0..16) -> Image (TM) +TmCaseChrome._discsHm = {} -- typeIdx (0..16) -> Image (HM) +TmCaseChrome._hmIcon = nil +TmCaseChrome._manifest = nil +TmCaseChrome._logged = false + +local function cache_root() + return Extract.CACHE_ROOT or "data/generated/gba" +end + +local function tm_root() + return cache_root() .. "/" .. TmCaseExtract.CACHE_SUB +end + +local function read_bytes(rel) + local cache = TmCaseChrome._cache + if cache and cache.read then + local d = cache:read(rel) + if type(d) == "string" and #d > 0 then return d end + end + local okD, Dataset = pcall(require, "src.core.game3.dataset") + if okD and Dataset and Dataset.cache then + local d = Dataset.cache():read(rel) + if type(d) == "string" and #d > 0 then return d end + end + local ok, CacheFs = pcall(require, "src.import.CacheFs") + if ok and CacheFs and CacheFs.readActive then + local d = CacheFs.readActive(rel) + if type(d) == "string" and #d > 0 then return d end + end + if love and love.filesystem and love.filesystem.read then + local d = love.filesystem.read(rel) + if type(d) == "string" and #d > 0 then return d end + local alt = "data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")) + d = love.filesystem.read(alt) + if type(d) == "string" and #d > 0 then return d end + end + local candidates = { + rel, + "data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")), + } + for _, p in ipairs(candidates) do + local f = io.open(p, "rb") + if f then + local d = f:read("*a") + f:close() + if d and #d > 0 then return d end + end + end + return nil +end + +local function rgba_to_image(rgba, w, h) + if not (love and love.image and love.graphics) then return nil end + local okD, data = pcall(love.image.newImageData, w, h) + if not okD or not data then return nil end + local ffiOk, ffi = pcall(require, "ffi") + if ffiOk and ffi and data.getFFIPointer then + local ptr = data:getFFIPointer() + ffi.copy(ptr, rgba, w * h * 4) + else + local p = 1 + for y = 0, h - 1 do + for x = 0, w - 1 do + local r = rgba:byte(p) / 255 + local g = rgba:byte(p + 1) / 255 + local b = rgba:byte(p + 2) / 255 + local a = rgba:byte(p + 3) / 255 + p = p + 4 + data:setPixel(x, y, r, g, b, a) + end + end + end + local img = love.graphics.newImage(data) + if img and img.setFilter then + img:setFilter("nearest", "nearest") + end + return img +end + +function TmCaseChrome.ready() + if TmCaseChrome._bgMale then return true end + local d = read_bytes(tm_root() .. "/bg_male.rgba") + return d ~= nil and #d > 0 +end + +function TmCaseChrome.loadBg(female) + if female and TmCaseChrome._bgFemale then return TmCaseChrome._bgFemale end + if not female and TmCaseChrome._bgMale then return TmCaseChrome._bgMale end + local filename = female and "/bg_female.rgba" or "/bg_male.rgba" + local raw = read_bytes(tm_root() .. filename) + if not raw or #raw < 240 * 160 * 4 then return nil end + local img = rgba_to_image(raw, 240, 160) + if female then + TmCaseChrome._bgFemale = img + else + TmCaseChrome._bgMale = img + end + return img +end + +function TmCaseChrome.loadCover(female) + if female and TmCaseChrome._coverFemale then return TmCaseChrome._coverFemale end + if not female and TmCaseChrome._coverMale then return TmCaseChrome._coverMale end + local filename = female and "/cover_female.rgba" or "/cover_male.rgba" + local raw = read_bytes(tm_root() .. filename) + if not raw or #raw < 240 * 160 * 4 then return nil end + local img = rgba_to_image(raw, 240, 160) + if female then + TmCaseChrome._coverFemale = img + else + TmCaseChrome._coverMale = img + end + return img +end + +function TmCaseChrome.loadDisc(typeIdx, isHm) + typeIdx = tonumber(typeIdx) or 0 + local tbl = isHm and TmCaseChrome._discsHm or TmCaseChrome._discs + if tbl[typeIdx] then return tbl[typeIdx] end + local filename = isHm and string.format("%s/disc_hm_%d.rgba", tm_root(), typeIdx) + or string.format("%s/disc_%d.rgba", tm_root(), typeIdx) + local raw = read_bytes(filename) + if not raw or #raw < 32 * 32 * 4 then + if isHm then + -- Fallback to standard TM disc if HM disc not baked yet + return TmCaseChrome.loadDisc(typeIdx, false) + end + return nil + end + local img = rgba_to_image(raw, 32, 32) + tbl[typeIdx] = img + return img +end + +function TmCaseChrome.loadHmIcon() + if TmCaseChrome._hmIcon then return TmCaseChrome._hmIcon end + local raw = read_bytes(tm_root() .. "/hm_icon.rgba") + if not raw or #raw < 16 * 12 * 4 then return nil end + local img = rgba_to_image(raw, 16, 12) + TmCaseChrome._hmIcon = img + return img +end + +function TmCaseChrome.drawBg(x, y, opts) + opts = opts or {} + local img = TmCaseChrome.loadBg(opts.female) + if img then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x or 0, y or 0) + return true + end + return false +end + +function TmCaseChrome.drawCover(x, y, opts) + opts = opts or {} + local img = TmCaseChrome.loadCover(opts.female) + if img then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x or 0, y or 0) + return true + end + return false +end + +function TmCaseChrome.drawDisc(typeIdx, x, y, isHm) + local img = TmCaseChrome.loadDisc(typeIdx, isHm) + if img then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x, y) + return true + end + return false +end + +function TmCaseChrome.drawHmIcon(x, y) + local img = TmCaseChrome.loadHmIcon() + if img then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x, y) + return true + end + return false +end + +return TmCaseChrome diff --git a/src/ui/game3/trainer_card.lua b/src/ui/game3/trainer_card.lua index 7ef9f8b6..202fddfc 100644 --- a/src/ui/game3/trainer_card.lua +++ b/src/ui/game3/trainer_card.lua @@ -142,29 +142,112 @@ local function count_caught(dex) return n end +local BADGE_FLAGS = { 0x820, 0x821, 0x822, 0x823, 0x824, 0x825, 0x826, 0x827 } +local BADGE_NAMES = { "BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH" } + +local function check_flags_table(flags, flagId, flagName) + if not flags or type(flags) ~= "table" then return false end + if flags[flagId] == true or (tonumber(flags[flagId]) or 0) > 0 then return true end + if flags[tostring(flagId)] == true or (tonumber(flags[tostring(flagId)]) or 0) > 0 then return true end + local hex = string.format("0x%X", flagId) + if flags[hex] == true or (tonumber(flags[hex]) or 0) > 0 then return true end + if flagName and (flags[flagName] == true or (tonumber(flags[flagName]) or 0) > 0) then return true end + return false +end + local function is_badge_unlocked(session, badgeIndex) - if not session then return false end - if session.badges then - if type(session.badges) == "table" then - if session.badges[badgeIndex] == true or (tonumber(session.badges[badgeIndex]) or 0) > 0 then + if not badgeIndex or badgeIndex < 1 or badgeIndex > 8 then return false end + local flagId = BADGE_FLAGS[badgeIndex] or (0x820 + badgeIndex - 1) + local bName = BADGE_NAMES[badgeIndex] + local flagName = string.format("FLAG_BADGE0%d_GET", badgeIndex) + + -- 1. Direct session badges table / number / boolean fields + if session then + if session.badges then + if type(session.badges) == "table" then + if session.badges[badgeIndex] == true or (tonumber(session.badges[badgeIndex]) or 0) > 0 then + return true + end + if bName and (session.badges[bName] == true or session.badges[bName:lower()] == true + or session.badges[bName .. "_BADGE"] == true or session.badges[(bName .. "_BADGE"):lower()] == true + or (tonumber(session.badges[bName]) or 0) > 0) then + return true + end + if session.badges[flagId] == true or session.badges[tostring(flagId)] == true or session.badges[flagName] == true then + return true + end + elseif type(session.badges) == "number" then + local mask = bit and bit.lshift(1, badgeIndex - 1) or math.pow(2, badgeIndex - 1) + if (bit and bit.band(session.badges, mask) ~= 0) or (math.floor(session.badges / mask) % 2 == 1) then + return true + end + end + end + + if session["badge" .. badgeIndex] == true or session["badge_" .. badgeIndex] == true then return true end + if bName and (session[bName .. "_BADGE"] == true or session[(bName .. "_BADGE"):lower()] == true + or session[bName] == true or session[bName:lower()] == true) then + return true + end + + -- Direct session flags + if check_flags_table(session.flags, flagId, flagName) then return true end + + -- Session store flags + if session.store and check_flags_table(session.store.flags, flagId, flagName) then return true end + + -- Save / player badges + local save = session.save or (session.game and session.game.save) or session + if save and save.player and save.player.badges then + local pb = save.player.badges + if pb[badgeIndex] == true or pb[flagId] == true or (bName and pb[bName] == true) then return true end - elseif type(session.badges) == "number" then - local mask = bit and bit.lshift(1, badgeIndex - 1) or math.pow(2, badgeIndex - 1) - if bit and bit.band(session.badges, mask) ~= 0 then return true end + end + if save and save.flags and check_flags_table(save.flags, flagId, flagName) then return true end + end + + -- 2. Scripting Space store fallback + local Space = package.loaded["src.core.game3.scripting.space"] + local store = (Space and Space.getStore and Space.getStore()) or (Space and Space.store) + if store and check_flags_table(store.flags, flagId, flagName) then return true end + + -- 3. Runtime active session fallback + local okRt, Runtime = pcall(require, "src.core.game3.runtime") + if okRt and Runtime and Runtime.getSession then + local rtSess = Runtime.getSession() + if rtSess and rtSess ~= session then + if check_flags_table(rtSess.flags, flagId, flagName) then return true end + if rtSess.store and check_flags_table(rtSess.store.flags, flagId, flagName) then return true end end end - if session["badge" .. badgeIndex] == true then return true end - if session.flags then - local flagId = 0x820 + (badgeIndex - 1) -- FLAG_BADGE01_GET - if session.flags[flagId] == true then return true end - end + return false end +function TrainerCard.isBadgeUnlocked(badgeIndex) + return is_badge_unlocked(TrainerCard._session, badgeIndex) +end + +function TrainerCard.countBadges(session) + local s = session or TrainerCard._session + local n = 0 + for i = 1, 8 do + if is_badge_unlocked(s, i) then n = n + 1 end + end + return n +end + +TrainerCard.side = "front" + +function TrainerCard.flip() + TrainerCard.side = (TrainerCard.side == "back") and "front" or "back" +end + function TrainerCard.show(opts) opts = opts or {} TrainerCard.open = true + TrainerCard.side = "front" TrainerCard._session = opts.session TrainerCard._onClose = opts.onClose ensureAssets() @@ -173,6 +256,8 @@ end function TrainerCard.close() TrainerCard.open = false + TrainerCard.side = "front" + TrainerCard._session = nil Stack.pop("trainer") local cb = TrainerCard._onClose TrainerCard._onClose = nil @@ -183,11 +268,29 @@ function TrainerCard.isOpen() return TrainerCard.open end +function TrainerCard.handleInput(inp) + if not TrainerCard.open or not inp then return end + if inp:wasPressed("b") then + TrainerCard.close() + return + end + if inp:wasPressed("a") then + TrainerCard.flip() + local ok, Audio = pcall(require, "src.core.game3.audio") + if ok and Audio and Audio.playSE then + Audio.playSE(93) + end + end +end + function TrainerCard.draw() if not TrainerCard.open then return end ensureAssets() local session = TrainerCard._session or {} local isFemale = (session.gender == "female" or session.gender == 1 or session.playerGender == "female") + local name = tostring(session.name or session.playerName or "RED") + local rawId = tonumber(session.trainerId or session.id or session.playerTrainerId) or 0 + local idStr = string.format("%05d", rawId % 65536) -- 1. Card Background local bg = isFemale and (_bgFemale or _bgMale) or (_bgMale or _bgFemale) @@ -195,7 +298,6 @@ function TrainerCard.draw() if bg then love.graphics.draw(bg, 0, 0) else - -- Fallback panel love.graphics.setColor(0.85, 0.55, 0.35, 1) love.graphics.rectangle("fill", 16, 8, 208, 144) love.graphics.setColor(0.98, 0.92, 0.78, 1) @@ -203,52 +305,151 @@ function TrainerCard.draw() love.graphics.setColor(1, 1, 1, 1) end - -- 2. Trainer Portrait (pret sTrainerPicOffsets: (19, 5) tiles -> (152, 40)) + if TrainerCard.side == "back" then + -- BACK SIDE RENDERING (pret trainer_card.c PrintAllOnCardBack) + -- Header: NAME & IDNo. + FrlgFont.draw("NAME:", 28, 24, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(name, 68, 24, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("IDNo. " .. idStr, 150, 18, { colors = FrlgFont.COLOR.NORMAL }) + + -- 1. HALL OF FAME DEBUT (pret y=35 in window -> screen y=43, label x=18, stat x=172) + FrlgFont.draw("HOF DEBUT", 28, 43, { colors = FrlgFont.COLOR.NORMAL }) + local hofStr = "---" + if session.hofDebutTime then + if type(session.hofDebutTime) == "table" then + local h = session.hofDebutTime.hours or 0 + local m = session.hofDebutTime.minutes or 0 + local s = session.hofDebutTime.seconds or 0 + hofStr = string.format("%d:%02d:%02d", h, m, s) + else + hofStr = tostring(session.hofDebutTime) + end + elseif session.hofDebutHours or session.hofDebutMinutes then + local h = tonumber(session.hofDebutHours) or 0 + local m = tonumber(session.hofDebutMinutes) or 0 + local s = tonumber(session.hofDebutSeconds) or 0 + hofStr = string.format("%d:%02d:%02d", h, m, s) + elseif session.flags and session.flags[0x82C] then + local pt = session.playtime or session.playTime + local h = tonumber(session.playTimeHours or session.hours or (pt and pt.hours)) or 0 + local m = tonumber(session.playTimeMinutes or session.minutes or (pt and pt.minutes)) or 0 + hofStr = string.format("%d:%02d", h, m) + end + FrlgFont.draw(hofStr, 152, 43, { colors = (hofStr == "---") and FrlgFont.COLOR.NORMAL or FrlgFont.COLOR.STAT }) + + -- 2. LINK BATTLES + local wins = tonumber(session.linkBattleWins) or 0 + local losses = tonumber(session.linkBattleLosses) or 0 + FrlgFont.draw("LINK BATTLES", 28, 62, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw("W:", 136, 62, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("%d", wins), 152, 62, { colors = FrlgFont.COLOR.STAT }) + FrlgFont.draw("L:", 184, 62, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("%d", losses), 200, 62, { colors = FrlgFont.COLOR.STAT }) + + -- 3. POKéMON TRADES + local trades = tonumber(session.pokemonTrades or session.trades) or 0 + FrlgFont.draw("POKéMON TRADES", 28, 80, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("%d", trades), 186, 80, { colors = FrlgFont.COLOR.STAT }) + + -- 4. UNION ROOM + local unionNum = tonumber(session.unionRoomNum or session.unionTrades) or 0 + FrlgFont.draw("UNION ROOM", 28, 98, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("%d", unionNum), 186, 98, { colors = FrlgFont.COLOR.STAT }) + + -- 5. Party Pokémon Slots / Stickers (Bottom Row) + if session.party and #session.party > 0 then + for i = 1, math.min(6, #session.party) do + local mon = session.party[i] + local mx = 28 + (i - 1) * 30 + local my = 120 + if mon and not (mon.isEgg or (type(mon.egg) == "boolean" and mon.egg)) then + local lvl = tonumber(mon.level) or 1 + FrlgFont.draw(string.format("L%d", lvl), mx, my, { colors = FrlgFont.COLOR.NORMAL }) + end + end + end + return + end + + -- FRONT SIDE RENDERING + -- 2. Trainer Portrait (pret sTrainerPicOffsets: window (152, 40) + offset (13, 4) -> screen (165, 44)) local pic = isFemale and (_picLeaf or _picRed) or (_picRed or _picLeaf) if pic then - love.graphics.draw(pic, 152, 40) + love.graphics.draw(pic, 165, 44) end -- 3. Card Labels & Values (pret trainer_card.c relative to window (8, 8)) - -- NAME: (pret x=20, y=29 in window -> (28, 37)) - local name = tostring(session.name or session.playerName or "RED") FrlgFont.draw("NAME:", 28, 37, { colors = FrlgFont.COLOR.NORMAL }) FrlgFont.draw(name, 68, 37, { colors = FrlgFont.COLOR.NORMAL }) - -- IDNo. (pret x=142, y=10 in window -> (150, 18) inside top-right oval pill) - local rawId = tonumber(session.trainerId or session.id or session.playerTrainerId) or 0 - local idStr = string.format("%05d", rawId % 65536) FrlgFont.draw("IDNo. " .. idStr, 150, 18, { colors = FrlgFont.COLOR.NORMAL }) - -- MONEY (pret x=20, y=56 in window -> (28, 64)) + -- MONEY (pret x=20, y=56 in window -> screen (28, 64), right aligned at window x=134 -> screen x=142) local money = tonumber(session.money) or 0 FrlgFont.draw("MONEY", 28, 64, { colors = FrlgFont.COLOR.NORMAL }) - local moneyStr = string.format("$%d", money) + local moneyStr = string.format("¥%d", money) local moneyW = FrlgFont.measure(moneyStr) - FrlgFont.draw(moneyStr, math.max(68, 126 - moneyW), 64, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(moneyStr, math.max(68, 142 - moneyW), 64, { colors = FrlgFont.COLOR.NORMAL }) - -- POKéDEX (pret x=20, y=72 in window -> (28, 80)) + -- POKéDEX (pret x=20, y=72 in window -> screen (28, 80), right aligned at window x=136 -> screen x=144) local caught = count_caught(session.dex) or tonumber(session.caughtMonsCount) or 0 FrlgFont.draw("POKéDEX", 28, 80, { colors = FrlgFont.COLOR.NORMAL }) local dexStr = string.format("%d", caught) local dexW = FrlgFont.measure(dexStr) - FrlgFont.draw(dexStr, math.max(68, 126 - dexW), 80, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(dexStr, math.max(68, 144 - dexW), 80, { colors = FrlgFont.COLOR.NORMAL }) + + -- TIME (pret x=20, y=88 in window -> screen (28, 96)) + local pt = session.playtime or session.playTime + local hours = tonumber(session.playTimeHours or session.hours or (pt and pt.hours)) or 0 + local mins = tonumber(session.playTimeMinutes or session.minutes or (pt and pt.minutes)) or 0 + hours = math.min(999, math.max(0, math.floor(hours))) + mins = math.min(59, math.max(0, math.floor(mins))) - -- TIME (pret x=20, y=88 in window -> (28, 96)) - local hours = tonumber(session.playTimeHours or session.hours) or 0 - local mins = tonumber(session.playTimeMinutes or session.minutes) or 0 FrlgFont.draw("TIME", 28, 96, { colors = FrlgFont.COLOR.NORMAL }) - local timeStr = string.format("%d:%02d", hours, mins) - FrlgFont.draw(timeStr, 88, 96, { colors = FrlgFont.COLOR.NORMAL }) + + -- Hours right-aligned to colon at window x=119 -> screen x=127 + local hoursStr = string.format("%d", hours) + local hoursW = FrlgFont.measure(hoursStr) + FrlgFont.draw(hoursStr, 127 - hoursW, 96, { colors = FrlgFont.COLOR.NORMAL }) + + -- Blinking colon at screen x=127, y=96 (toggles every 30 frames / 0.5s) + local colonOn = true + if love and love.timer and love.timer.getTime then + colonOn = (math.floor(love.timer.getTime() * 2) % 2 == 0) + end + if colonOn then + FrlgFont.draw(":", 127, 96, { colors = FrlgFont.COLOR.NORMAL }) + end + + -- Minutes (2 digits with leading zero) at window x=124 -> screen x=132 + local minsStr = string.format("%02d", mins) + FrlgFont.draw(minsStr, 132, 96, { colors = FrlgFont.COLOR.NORMAL }) -- 4. Badges (pret tile 16 -> y = 128, x = 32, 56, 80, 104, 128, 152, 176, 200) - if _badgesImg and _badgeQuads then - for i = 1, 8 do - local bx = 32 + (i - 1) * 24 - local by = 128 - if is_badge_unlocked(session, i) then + for i = 1, 8 do + local bx = 32 + (i - 1) * 24 + local by = 128 + if is_badge_unlocked(session, i) then + if _badgesImg and _badgeQuads and _badgeQuads[i] then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(_badgesImg, _badgeQuads[i], bx, by) + else + -- Fallback badge representation in authentic Kanto colors + local colors = { + { 0.65, 0.65, 0.65 }, -- 1. Boulder (Gray) + { 0.20, 0.60, 0.95 }, -- 2. Cascade (Blue teardrop) + { 0.95, 0.85, 0.20 }, -- 3. Thunder (Yellow star) + { 0.30, 0.85, 0.40 }, -- 4. Rainbow (Green flower) + { 0.90, 0.30, 0.70 }, -- 5. Soul (Pink heart) + { 0.80, 0.70, 0.30 }, -- 6. Marsh (Gold disc) + { 0.95, 0.35, 0.20 }, -- 7. Volcano (Red flame) + { 0.30, 0.75, 0.35 }, -- 8. Earth (Green feather) + } + local c = colors[i] or { 0.8, 0.8, 0.8 } + love.graphics.setColor(c[1], c[2], c[3], 1) + love.graphics.rectangle("fill", bx + 2, by + 2, 12, 12) + love.graphics.setColor(1, 1, 1, 0.9) + love.graphics.rectangle("line", bx + 2, by + 2, 12, 12) end end end diff --git a/tests/game3_audio_test.lua b/tests/game3_audio_test.lua index 594c1fb8..227336cf 100644 --- a/tests/game3_audio_test.lua +++ b/tests/game3_audio_test.lua @@ -325,12 +325,62 @@ do assert_true(math.abs(voice.step - expect) < 1e-9, "step matches trackPitch after BEND") end --- Pan tokens + SE MusicPlayer exclusivity helpers. +-- LFO modulation (vibrato) must dynamically change pitch do - local Audio = require("src.core.game3.audio") - assert_eq(Audio.normalizePan("SOUND_PAN_TARGET"), 63, "pan target") - assert_eq(Audio.normalizePan("SOUND_PAN_ATTACKER"), -64, "pan attacker") - assert_eq(Audio.normalizePan(20), 20, "pan numeric") + local wavFreq = 13700096 + local song = { + tracks = 1, + trackData = { + string.char( + 0xC2, 30, -- LFOS 30 + 0xC3, 0, -- LFODL 0 + 0xC4, 60, -- MOD 60 + 0xC5, 0, -- MODT 0 (pitch) + 0xD0, 60, 127, 48, -- N96 key=60 vel=127 gate=48 + 0x88, -- WAIT 8 + 0xB1 -- FINE + ), + }, + } + local p = Seq.newPlayer(song, { + voiceResolver = function(_vn, key, _vel, _tr, volL, volR, fine) + local rate = Mix.midiKeyToFreq(wavFreq, key, fine or 0) + return { + kind = "ds", + alive = true, + wavFreq = wavFreq, + step = rate / Mix.SAMPLE_RATE, + volL = volL, + volR = volR, + fixedFreq = false, + } + end, + }) + p.tempo = 150 + p.tempoC = 0 + Seq.update(p, 1) + local voice = p.voices[1] + assert_true(voice ~= nil, "lfo voice created") + local initialStep = voice.step + local vibratoObserved = false + for _ = 1, 10 do + Seq.update(p, 1) + if math.abs(voice.step - initialStep) > 1e-4 then + vibratoObserved = true + end + end + assert_true(vibratoObserved, "LFO modulation dynamically modulates active voice pitch") +end + +-- Looped sample rendering preserves fractional phase and renders without errors +do + local pcm = string.char(0, 50, 100, 50, 0, 206, 156, 206) + local v = Mix.newDsVoice(pcm, { size = 8, freq = 13379 * 1024, loopStart = 2 }, { loop = true, rate = Mix.SAMPLE_RATE }) + v.step = 0.75 + local voices = { v } + local outL, outR, alive = Mix.render(voices, 64, { raw = true }) + assert_true(#alive > 0, "looped voice stays alive") + assert_true(v.pos > 2 and v.pos < 8, "pos stays within loop bounds") end print("game3_audio_test: ok") diff --git a/tests/game3_bag_test.lua b/tests/game3_bag_test.lua index 18f3a0ca..37f684b2 100644 --- a/tests/game3_bag_test.lua +++ b/tests/game3_bag_test.lua @@ -197,6 +197,38 @@ check(e2 == false, "escape rope refused outdoors") sess.registeredItem = 360 check(sess.registeredItem == 360, "registered BICYCLE") +print("[test] 8. Berry & TM pocket segregation and 3-pocket Bag UI") +local testBag = Bag.new() +Bag.add(testBag, "POTION", 2) +Bag.add(testBag, "BERRY", 3) +Bag.add(testBag, "ORAN_BERRY", 2) +Bag.add(testBag, "CHESTO_BERRY", 1) +Bag.add(testBag, "TM01", 1) +Bag.add(testBag, "HM01", 1) + +local itemRows = Bag.listPocket(testBag, "ITEMS") +local keyRows = Bag.listPocket(testBag, "KEY_ITEMS") +local tmRows = Bag.listPocket(testBag, "TM_CASE") +local berryRows = Bag.listPocket(testBag, "BERRY_POUCH") + +check(#itemRows == 1 and itemRows[1].name == "POTION", "ITEMS pocket only contains general items") +check(Bag.has(testBag, ItemsData.ITEM_TM_CASE, 1), "TM CASE key item present in KEY_ITEMS") +check(Bag.has(testBag, ItemsData.ITEM_BERRY_POUCH, 1), "BERRY POUCH key item present in KEY_ITEMS") +check(#tmRows == 2, "TM_CASE pocket contains 2 machines") +check(#berryRows == 2, "BERRY_POUCH pocket contains 2 berry kinds") +check(Bag.get(testBag, 139) == 5, "BERRY + ORAN_BERRY merged to 5 Oran berries") + +local BagMenu = require("src.ui.game3.bag_menu") +BagMenu.show(nil, testBag) +check(BagMenu.currentPocket() == "ITEMS", "BagMenu initial pocket is ITEMS") +BagMenu.handleInput({ wasPressed = function(s, k) return k == "right" end }) +check(BagMenu.currentPocket() == "KEY_ITEMS", "BagMenu second pocket is KEY_ITEMS") +BagMenu.handleInput({ wasPressed = function(s, k) return k == "right" end }) +check(BagMenu.currentPocket() == "POKE_BALLS", "BagMenu third pocket is POKE_BALLS") +BagMenu.handleInput({ wasPressed = function(s, k) return k == "right" end }) +check(BagMenu.currentPocket() == "ITEMS", "BagMenu wraps back to ITEMS (3 pockets only)") +BagMenu.close() + if failed > 0 then print(string.format("\n%d FAILED", failed)) os.exit(1) diff --git a/tests/game3_battle_anims_coverage_test.lua b/tests/game3_battle_anims_coverage_test.lua new file mode 100644 index 00000000..197800eb --- /dev/null +++ b/tests/game3_battle_anims_coverage_test.lua @@ -0,0 +1,161 @@ +-- Comprehensive Battle Animations Coverage Test +-- Verifies all 354 moves in pack.lua, testing GLSL shader integration, +-- Z-index depth, nearest-neighbor scaling tasks, and spatial audio panning. + +local Anim = require("src.core.game3.battle.anim") +local AnimVm = require("src.core.game3.battle.anim_vm") +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimCallbacks = require("src.core.game3.battle.anim_callbacks") + +local passed = 0 +local failed = 0 + +local function check(cond, name) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("[FAIL] " .. tostring(name)) + end +end + +print("=== Battle Animations System Coverage Test ===") + +-- 1. GLSL Screen Shader & Palette Effects +print("[test] 1. Screen effect shader lifecycle") +Anim.reset({ headless = true }) +check(Anim.screenEffect().type == "none", "screen effect initialized to none") + +AnimTasks.spawn("InvertScreenColor", 2, { 16 }, Anim.vm()) +check(AnimTasks.activeCount() == 1, "InvertScreenColor task spawned") + +-- Step 1 frame +AnimTasks.update(Anim.vm()) +check(Anim.screenEffect().type == "invert", "InvertScreenColor sets shader to invert") + +-- Run through duration +for _ = 1, 20 do + AnimTasks.update(Anim.vm()) +end +check(AnimTasks.activeCount() == 0, "InvertScreenColor task terminated cleanly") +check(Anim.screenEffect().type == "none", "InvertScreenColor restored shader to none") + +-- FadeScreenToWhite +AnimTasks.spawn("FadeScreenToWhite", 2, { 16 }, Anim.vm()) +AnimTasks.update(Anim.vm()) +check(Anim.screenEffect().type == "fade_white", "FadeScreenToWhite sets shader to fade_white") +for _ = 1, 20 do + AnimTasks.update(Anim.vm()) +end +check(Anim.screenEffect().type == "none", "FadeScreenToWhite restored shader to none") + +-- SetGrayscaleOrOriginalPal +AnimTasks.spawn("SetGrayscaleOrOriginalPal", 2, { 16 }, Anim.vm()) +AnimTasks.update(Anim.vm()) +check(Anim.screenEffect().type == "grayscale", "SetGrayscaleOrOriginalPal sets shader to grayscale") +for _ = 1, 20 do + AnimTasks.update(Anim.vm()) +end +check(Anim.screenEffect().type == "none", "SetGrayscaleOrOriginalPal restored shader to none") + +-- 2. Z-Index Depth & Layering +print("[test] 2. Z-Index depth and layering rules") +AnimSprites.reset() + +-- Physical contact -> GLOBAL_FRONT (Z = 900) +local sprFist = AnimSprites.acquire({ + z = AnimSprites.Z.GLOBAL_FRONT, + callback = AnimCallbacks.get("BasicFistOrFoot"), +}) +check(sprFist and sprFist.z == AnimSprites.Z.GLOBAL_FRONT, "BasicFistOrFoot assigned to GLOBAL_FRONT (900)") + +-- Ground hazard -> GLOBAL_BEHIND (Z = 10) +local sprMud = AnimSprites.acquire({ + z = AnimSprites.Z.GLOBAL_BEHIND, + callback = AnimCallbacks.get("MudSportDirt"), +}) +check(sprMud and sprMud.z == AnimSprites.Z.GLOBAL_BEHIND, "MudSportDirt assigned to GLOBAL_BEHIND (10)") + +local list = AnimSprites.sortedDrawList() +check(#list == 2, "2 sprites in sorted list") +check(list[1].z < list[2].z, "Background mud renders before foreground fist") +AnimSprites.reset() + +-- 3. Audio Panning Task +print("[test] 3. Audio spatial panning task") +local pTask = AnimTasks.spawn("SoundTask_PlaySE1WithPanning", 2, { 5, -64, 63, 10 }, Anim.vm()) +check(pTask ~= nil, "SoundTask_PlaySE1WithPanning spawned") +AnimTasks.update(Anim.vm()) +check(pTask.data[10] == -64, "Initial pan is -64 (attacker)") +for _ = 1, 5 do + AnimTasks.update(Anim.vm()) +end +check(pTask.data[10] > -64 and pTask.data[10] < 63, "Pan sweeps across midpoint") +for _ = 1, 10 do + AnimTasks.update(Anim.vm()) +end +check(AnimTasks.activeCount() == 0, "Panning task terminates cleanly") + +-- 4. Execute all 354 Move Scripts through VM +print("[test] 4. Full 354-move bytecode execution coverage") +local ok, Dataset = pcall(require, "src.core.game3.dataset") +local cache = ok and Dataset.cache and Dataset.cache() or nil +local packSrc = cache and cache.read and cache:read("data/generated/gba/pokemon/battle_anims/pack.lua") +if not packSrc then + local f = io.open("data/generated/gba/pokemon/battle_anims/pack.lua", "r") + if f then packSrc = f:read("*a"); f:close() end +end + +check(type(packSrc) == "string" and #packSrc > 0, "pack.lua source loaded") + +local chunk = loadstring and loadstring(packSrc) or load(packSrc) +local pack = chunk() +check(type(pack) == "table" and type(pack.moves) == "table", "pack.moves table parsed") + +local moveCount = 0 +local cleanFinishes = 0 +local vmErrors = 0 + +local vm = AnimVm.new() +vm:setPack(pack) + +for moveId, script in pairs(pack.moves) do + moveCount = moveCount + 1 + AnimTasks.reset() + AnimSprites.reset() + Anim.reset({ headless = true }) + + local okLaunch, err = pcall(function() + vm:launch(script, { attackerSide = "player", isReversed = false }) + end) + + if not okLaunch then + vmErrors = vmErrors + 1 + print(string.format("[ERROR] Move %s launch error: %s", tostring(moveId), tostring(err))) + else + -- Step VM up to 360 frames (6 seconds max for multi-hit/looping moves) + local maxTicks = 360 + local ticks = 0 + while vm:busy() and ticks < maxTicks do + ticks = ticks + 1 + vm:update(1 / 60) + end + + if not vm:busy() then + cleanFinishes = cleanFinishes + 1 + else + print(string.format("[TIMEOUT] Move %s timed out after %d frames", tostring(moveId), maxTicks)) + end + end +end + +print(string.format("Tested %d moves: %d completed cleanly, %d launch errors", moveCount, cleanFinishes, vmErrors)) +check(moveCount >= 354, "All 354 moves present and processed") +check(vmErrors == 0, "0 VM launch errors across all moves") +check(cleanFinishes == moveCount, "All moves cleanly terminate without freezing") + +print(string.format("\nResults: %d Passed, %d Failed", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_battle_anims_phase1_test.lua b/tests/game3_battle_anims_phase1_test.lua new file mode 100644 index 00000000..b4aa8484 --- /dev/null +++ b/tests/game3_battle_anims_phase1_test.lua @@ -0,0 +1,182 @@ +-- Phase 1 Battle Animation Primitives Parity Test +-- Validates exact GBA math, 60fps frame stepping, Z-indexing, and side-awareness. + +local Anim = require("src.core.game3.battle.anim") +local AnimVm = require("src.core.game3.battle.anim_vm") +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") + +local passed = 0 +local failed = 0 + +local function check(cond, name) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("[FAIL] " .. tostring(name)) + end +end + +print("=== Phase 1 Battle Animation Primitives Test ===") + +-- 1. Coordinate Space & Sub-pixel Fixed Point Math (TranslateMonElliptical & RespectSide) +print("[test] 1. TranslateMonElliptical & Coordinate Mapping") +Anim.reset({ headless = true }) +local vm = Anim.vm() +local pAtk = Anim.present("player") + +-- Spawn elliptical translation for player mon: radiusX=20, radiusY=10, 1 loop, speed=3 (wavePeriod=8) +AnimTasks.reset() +pAtk.ox = 0 +pAtk.oy = 0 +pAtk.z = AnimVm.Z.PLAYER + +local task = AnimTasks.spawn("TranslateMonElliptical", 2, { 0, 20, 10, 1, 3 }, vm) +check(task ~= nil, "TranslateMonElliptical spawned") + +-- Frame 1: Task inits, sets Z to FRONT, angle starts at 0 (evaluated at angle=0, next angle=8) +AnimTasks.update(vm) +check(pAtk.z == AnimVm.Z.FRONT, "Attacker Z-index elevated during elliptical translation") + +-- Step 8 more frames -> frame 9 total (evaluated at angle = 8 * 8 = 64 = 90 deg -> sin(64)=1.0, -cos(64)+1.0 = 1.0) +for _ = 1, 8 do + AnimTasks.update(vm) +end +check(pAtk.ox == 20, "X offset at 90 deg is exactly radiusX (20), got: " .. tostring(pAtk.ox)) +check(pAtk.oy == 10, "Y offset at 90 deg is exactly radiusY (10), got: " .. tostring(pAtk.oy)) + +-- Step remaining 24 frames to complete loop (33 frames total for 32 angle steps + completion) +for _ = 1, 24 do + AnimTasks.update(vm) +end +check(AnimTasks.activeCount() == 0, "TranslateMonElliptical terminated after 1 loop") +check(pAtk.ox == 0 and pAtk.oy == 0, "Clean return to (0,0) with no coordinate drift") +check(pAtk.z == AnimVm.Z.PLAYER, "Attacker Z-index restored to PLAYER after translation") + +-- 2. Side-Awareness Logic (Player vs Opponent Lunges & Sways) +print("[test] 2. Side-awareness vector flipping") + +-- Player horizontal lunge (forward = +X) +AnimTasks.reset() +pAtk.ox = 0 +pAtk.oy = 0 +pAtk.z = AnimVm.Z.PLAYER +vm._attackerSide = "player" + +AnimTasks.spawn("DoHorizontalLunge", 2, { 4, 6 }, vm) +AnimTasks.update(vm) -- Frame 1: inits, Z elevated, ox = +6 +check(pAtk.z == AnimVm.Z.FRONT, "Attacker elevated to FRONT during lunge") +for _ = 2, 4 do AnimTasks.update(vm) end -- Frames 2..4 -> ox reaches +24 +check(pAtk.ox == 24, "Player lunge forward gives +24 X, got: " .. tostring(pAtk.ox)) + +for _ = 5, 8 do AnimTasks.update(vm) end -- Frames 5..8 -> ox steps back to 0 +AnimTasks.update(vm) -- Frame 9: finishes & cleans up +check(AnimTasks.activeCount() == 0, "Player lunge completed cleanly") +check(pAtk.ox == 0, "Player lunge returned to 0 X") +check(pAtk.z == AnimVm.Z.PLAYER, "Player Z restored to PLAYER") + +-- Enemy horizontal lunge (forward = -X) +AnimTasks.reset() +local pEnemy = Anim.present("enemy") +pEnemy.ox = 0 +pEnemy.oy = 0 +pEnemy.z = AnimVm.Z.ENEMY +vm._attackerSide = "enemy" + +AnimTasks.spawn("DoHorizontalLunge", 2, { 4, 6 }, vm) +AnimTasks.update(vm) -- Frame 1: inits, Z elevated, ox = -6 +check(pEnemy.z == AnimVm.Z.FRONT, "Enemy elevated to FRONT during lunge") +for _ = 2, 4 do AnimTasks.update(vm) end -- Frames 2..4 -> ox reaches -24 +check(pEnemy.ox == -24, "Enemy lunge forward gives -24 X, got: " .. tostring(pEnemy.ox)) + +for _ = 5, 8 do AnimTasks.update(vm) end -- Frames 5..8 -> ox steps back to 0 +AnimTasks.update(vm) -- Frame 9: finishes & cleans up +check(AnimTasks.activeCount() == 0, "Enemy lunge completed cleanly") +check(pEnemy.ox == 0, "Enemy lunge returned to 0 X") +check(pEnemy.z == AnimVm.Z.ENEMY, "Enemy Z restored to ENEMY") + +-- 3. Dynamic Z-Index Layering with SlideMonToOriginalPos +print("[test] 3. Dynamic Z-Index restoration on SlideMonToOriginalPos") +AnimTasks.reset() +vm._attackerSide = "player" +pAtk.ox = 30 +pAtk.oy = 15 +pAtk.z = AnimVm.Z.FRONT + +-- SlideMonToOriginalPos: 0=attacker, 0=both, 6 frames +AnimTasks.spawn("SlideMonToOriginalPos", 2, { 0, 0, 6 }, vm) +for _ = 1, 3 do AnimTasks.update(vm) end +check(pAtk.ox < 30 and pAtk.ox > 0, "Mid-slide interpolation progressing") +for _ = 1, 4 do AnimTasks.update(vm) end +check(AnimTasks.activeCount() == 0, "SlideMonToOriginalPos finished") +check(pAtk.ox == 0 and pAtk.oy == 0, "Offset restored to exact (0,0)") +check(pAtk.z == AnimVm.Z.PLAYER, "Z-index cleanly restored to PLAYER") + +-- 4. Frame-Clock Syncing & Exact Delay Shakes (ShakeMon, ShakeMon2, ShakeMonInPlace) +print("[test] 4. Shake tasks frame clock stepping") + +-- ShakeMon2: 0=player, 4=xOff, 0=yOff, 3=numShakes, 2=delay +AnimTasks.reset() +pAtk.ox = 0 +AnimTasks.spawn("ShakeMon2", 2, { 0, 4, 0, 3, 2 }, vm) +AnimTasks.update(vm) -- Frame 1 (init): ox = +4, timer = 2 +check(pAtk.ox == 4, "Frame 1 initial shake +4") +AnimTasks.update(vm) -- Frame 2: timer 2 -> 1 +check(pAtk.ox == 4, "Frame 2 hold +4") +AnimTasks.update(vm) -- Frame 3: timer 1 -> 0 +check(pAtk.ox == 4, "Frame 3 hold +4") +AnimTasks.update(vm) -- Frame 4: timer 0 -> toggles to -4, timer=2, numShakes=2 +check(pAtk.ox == -4, "Frame 4 toggles to -4") +AnimTasks.update(vm) -- Frame 5: timer 2 -> 1 +AnimTasks.update(vm) -- Frame 6: timer 1 -> 0 +AnimTasks.update(vm) -- Frame 7: toggles to +4, timer=2, numShakes=1 +check(pAtk.ox == 4, "Frame 7 toggles to +4") +AnimTasks.update(vm) +AnimTasks.update(vm) +AnimTasks.update(vm) -- shakes reached 0 -> destroys +check(AnimTasks.activeCount() == 0, "ShakeMon2 destroyed after exact 3 shakes") +check(pAtk.ox == 0, "Final offset restored to 0") + +-- 5. WindUpLunge 2-Stage Mechanics (Take Down, Double-Edge) +print("[test] 5. WindUpLunge 2-stage mechanics") +AnimTasks.reset() +pAtk.ox = 0 +pAtk.oy = 0 +vm._attackerSide = "player" +-- speed1 = -20 (subpixel), amp = 6, dur1 = 10, delay = 2, targetX = 30, dur2 = 5 +AnimTasks.spawn("WindUpLunge", 2, { 0, -20, 6, 10, 2, 30, 5 }, vm) + +-- Step through stage 1 (10 frames) +for _ = 1, 10 do AnimTasks.update(vm) end +check(pAtk.z == AnimVm.Z.FRONT, "Attacker elevated to FRONT during WindUpLunge") +check(pAtk.ox == -20, "Stage 1 wind-up reached -20 X, got: " .. tostring(pAtk.ox)) + +-- Step through delay (2 frames) +AnimTasks.update(vm) +AnimTasks.update(vm) + +-- Step through stage 2 lunge (5 frames) +for _ = 1, 5 do AnimTasks.update(vm) end +check(AnimTasks.activeCount() == 0, "WindUpLunge completed") +check(pAtk.ox == 10, "Final forward lunge reached (-20 + 30) = +10 X, got: " .. tostring(pAtk.ox)) +check(pAtk.z == AnimVm.Z.PLAYER, "Z-index restored to PLAYER") + +-- 6. Utility Tasks (GetAttackerSide, GetTargetSide) +print("[test] 6. Utility Side Query Tasks") +vm.args = {} +AnimTasks.reset() +vm._attackerSide = "player" +vm._targetSide = "enemy" +AnimTasks.spawn("GetAttackerSide", 2, {}, vm) +AnimTasks.update(vm) +check(vm.args[7] == 0, "GetAttackerSide for player returns 0") + +AnimTasks.spawn("GetTargetSide", 2, {}, vm) +AnimTasks.update(vm) +check(vm.args[7] == 1, "GetTargetSide for enemy returns 1") + +print(string.format("\nPhase 1 Results: %d Passed, %d Failed", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_battle_anims_phase2_test.lua b/tests/game3_battle_anims_phase2_test.lua new file mode 100644 index 00000000..8700f450 --- /dev/null +++ b/tests/game3_battle_anims_phase2_test.lua @@ -0,0 +1,384 @@ +-- Test suite for Phase 2: Palette Blends & Affine Battler Transformations +-- Run: luajit tests/game3_battle_anims_phase2_test.lua + +package.path = "?.lua;?/init.lua;" .. package.path + +local Anim = require("src.core.game3.battle.anim") +local AnimVm = require("src.core.game3.battle.anim_vm") +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") + +local passed = 0 +local failed = 0 + +local function test(name, fn) + Anim.reset({ headless = true }) + AnimTasks.reset() + AnimSprites.reset() + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(string.format(" PASS: %s", name)) + else + failed = failed + 1 + print(string.format(" FAIL: %s -> %s", name, tostring(err))) + end +end + +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)), 2) + end +end + +local function assert_near(a, b, eps, msg) + eps = eps or 0.001 + if math.abs((a or 0) - (b or 0)) > eps then + error(string.format("%s (expected ~%s, got %s)", msg or "assert_near failed", tostring(b), tostring(a)), 2) + end +end + +local function make_vm(atkSide) + local vm = AnimVm.new() + vm.headless = true + vm.attacker = (atkSide == "enemy") and 1 or 0 + vm.target = (atkSide == "enemy") and 0 or 1 + return vm +end + +print("=== Phase 2: Palette, Blend & Affine Transformations Tests ===") + +-- 1. BlendBattleAnimPal +test("BlendBattleAnimPal - background fade_black and restore", function() + local vm = make_vm("player") + -- palMask=1 (BG), delay=0, startCoeff=0, targetCoeff=8, color=0 (black) + AnimTasks.spawn("BlendBattleAnimPal", 2, { 1, 0, 0, 8, 0 }, vm) + assert_eq(AnimTasks.activeCount(), 1) + + for _ = 1, 9 do + AnimTasks.update(vm) + end + assert_eq(AnimTasks.activeCount(), 0, "task should finish after 9 steps (0..8)") + local fx = Anim.screenEffect() + assert_eq(fx.type, "fade_black") + assert_near(fx.coeff, 0.5) + + -- Fade back out to 0 + AnimTasks.spawn("BlendBattleAnimPal", 2, { 1, 0, 8, 0, 0 }, vm) + for _ = 1, 9 do + AnimTasks.update(vm) + end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(Anim.screenEffect().type, "none", "screen effect should be restored") +end) + +test("BlendBattleAnimPal - battler palette tinting", function() + local vm = make_vm("player") + local pAtk = Anim.present("player") + local pTgt = Anim.present("enemy") + -- palMask=2 (attacker), delay=0, start=0, target=16, color=RGB_WHITE (0x7FFF) + AnimTasks.spawn("BlendBattleAnimPal", 2, { 2, 0, 0, 16, 0x7FFF }, vm) + for _ = 1, 17 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_near(pAtk.blendCoeff, 1.0) + assert_eq(pAtk.flash, 1) + assert_eq(pTgt.blendCoeff or 0, 0) +end) + +-- 2. BlendBattleAnimPalExclude +test("BlendBattleAnimPalExclude - exclude target", function() + local vm = make_vm("player") + -- cmd 1: Not target -> blends attacker & BG (mask 3) + AnimTasks.spawn("BlendBattleAnimPalExclude", 2, { 1, 0, 0, 4, 0 }, vm) + for _ = 1, 5 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + local pAtk = Anim.present("player") + local pTgt = Anim.present("enemy") + assert_near(pAtk.blendCoeff, 0.25) + assert_eq(pTgt.blendCoeff or 0, 0) +end) + +-- 3. BlendMonInAndOut +test("BlendMonInAndOut - pulse in and out", function() + local vm = make_vm("player") + local p = Anim.present("player") + -- whichMon=0 (atk), color=0x7FFF (white), targetCoeff=4, delay=0, repeats=2 + AnimTasks.spawn("BlendMonInAndOut", 2, { 0, 0x7FFF, 4, 0, 2 }, vm) + assert_eq(AnimTasks.activeCount(), 1) + -- 2 repeats of 4 steps in + 4 steps out = 16 updates + for _ = 1, 20 do + AnimTasks.update(vm) + end + assert_eq(AnimTasks.activeCount(), 0, "task should finish after repeat cycles") + assert_eq(p.blendCoeff, 0) + assert_eq(p.flash, 0) +end) + +-- 4. TraceMonBlended +test("TraceMonBlended - spawn afterimages and lifetime cleanup", function() + local vm = make_vm("player") + -- whichMon=0, interval=2, lifetime=4, count=3 + AnimTasks.spawn("TraceMonBlended", 2, { 0, 2, 4, 3 }, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 20 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "task finishes after all afterimages expire") + assert_eq(AnimSprites.activeCount(), 0, "all afterimages should be released") +end) + +-- 5. AttackerFadeToInvisible & AttackerFadeFromInvisible +test("AttackerFadeToInvisible & FromInvisible", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("AttackerFadeToInvisible", 2, { 0 }, vm) + for _ = 1, 16 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.alpha, 0) + assert_eq(p.visible, false) + + AnimTasks.spawn("AttackerFadeFromInvisible", 2, { 0 }, vm) + AnimTasks.update(vm) + assert_eq(p.visible, true) + for _ = 2, 16 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.alpha, 1) +end) + +-- 6. RotateMonSpriteToSide +test("RotateMonSpriteToSide - rotate and restore", function() + local vm = make_vm("player") + local p = Anim.present("player") + -- duration=10, rotDelta=0x100, whichMon=0, returnMode=1 (reset on end) + AnimTasks.spawn("RotateMonSpriteToSide", 2, { 10, 0x100, 0, 1 }, vm) + AnimTasks.update(vm) + assert_eq(p.rotation ~= 0, true, "rotation should change") + for _ = 2, 10 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.rotation, 0, "rotation should be reset") + assert_eq(p.oy, 0, "vertical offset should be reset") +end) + +-- 7. ScaleMonAndRestore +test("ScaleMonAndRestore - scale and bounce back", function() + local vm = make_vm("player") + local p = Anim.present("player") + -- dx=8, dy=8, duration=8, whichMon=0 + AnimTasks.spawn("ScaleMonAndRestore", 2, { 8, 8, 8, 0 }, vm) + AnimTasks.update(vm) + assert_eq(p.sx ~= 1, true, "sx should change") + assert_eq(p.sy ~= 1, true, "sy should change") + -- 8 frames forward + 8 frames reverse = 16 frames + for _ = 2, 16 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1, "sx restored") + assert_eq(p.sy, 1, "sy restored") + assert_eq(p.oy, 0, "oy restored") +end) + +-- 8. Minimize +test("Minimize - 3 cycles and recover", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("Minimize", 2, {}, vm) + -- 3 cycles of 32 frames + 32 frames pause + 16 frames recover = 144 frames + for _ = 1, 150 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1) + assert_eq(p.sy, 1) + assert_eq(p.oy, 0) +end) + +-- 9. Withdraw +test("Withdraw - downward roll, pause, and roll back", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("Withdraw", 2, {}, vm) + -- 22 roll + 30 pause + 22 back = 74 frames + for _ = 1, 80 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.rotation, 0) + assert_eq(p.oy, 0) +end) + +-- 10. General Affine Tasks: DefenseCurl, Stockpile, SpitUp, Swallow, StretchTargetUp, GrowAndShrink +test("Affine Animations - DefenseCurl", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("DefenseCurlDeformMon", 2, {}, vm) + -- 2 loops of 16 frames = 48 frames + for _ = 1, 50 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1) + assert_eq(p.sy, 1) + assert_eq(p.oy, 0) +end) + +test("Affine Animations - StockpileDeformMon", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("StockpileDeformMon", 2, {}, vm) + for _ = 1, 40 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1) + assert_eq(p.sy, 1) + assert_eq(p.oy, 0) +end) + +test("Affine Animations - StretchTargetUp", function() + local vm = make_vm("player") + local p = Anim.present("enemy") + AnimTasks.spawn("StretchTargetUp", 2, {}, vm) + for _ = 1, 25 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1) + assert_eq(p.sy, 1) + assert_eq(p.oy, 0) +end) + +test("Affine Animations - GrowAndShrink (Bulk Up / Swagger)", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("GrowAndShrink", 2, {}, vm) + for _ = 1, 70 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.sx, 1) + assert_eq(p.sy, 1) + assert_eq(p.oy, 0) +end) + +test("AcidArmor - wave and fade cycle", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("AcidArmor", 2, { 0 }, vm) + for _ = 1, 85 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.alpha, 1) + assert_eq(p.ox, 0) +end) + +test("FacadeColorBlend - color cycle", function() + local vm = make_vm("player") + local p = Anim.present("player") + AnimTasks.spawn("FacadeColorBlend", 2, { 0, 24 }, vm) + AnimTasks.update(vm) + assert_eq(p.blendCoeff, 0.5) + for _ = 2, 25 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0) + assert_eq(p.blendCoeff, 0) +end) + +test("RockMonBackAndForth - rocking and rotation with side-awareness", function() + local vm = make_vm("player") + local p = Anim.present("player") + -- whichMon=0, numRocks=2, speedInc=0 + AnimTasks.spawn("RockMonBackAndForth", 2, { 0, 2, 0 }, vm) + AnimTasks.update(vm) + assert_eq(AnimTasks.activeCount(), 1) + assert_eq(p.ox ~= 0, true, "mon should move horizontally while rocking") + -- Step through all rocking cycles (halfDur=8: 8 + 16 + 8 = 32 frames per rock * 2 = 64 frames) + for _ = 2, 70 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0, "rocking task should complete") + assert_eq(p.ox, 0, "ox restored") + assert_eq(p.oy, 0, "oy restored") + assert_eq(p.rotation, 0, "rotation restored") +end) + +test("ComplexPaletteBlend - alternate tint cycling", function() + local vm = make_vm("player") + local p = Anim.present("player") + -- palMask=2 (attacker), delay=2, numCycles=2, color1=32767 (white), coeff1=16, color2=0 (black), coeff2=0 + AnimTasks.spawn("ComplexPaletteBlend", 2, { 2, 2, 2, 32767, 16, 0, 0 }, vm) + AnimTasks.update(vm) + assert_eq(AnimTasks.activeCount(), 1) + assert_eq(p.blendCoeff > 0, true, "initial tint active") + for _ = 2, 20 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0, "complex blend finishes") + assert_eq(p.blendCoeff, 0, "blend restored") +end) + +test("FlashAnimTagWithColor - flash cycle count and termination", function() + local vm = make_vm("player") + -- tag=10005, delay=2, numFlashes=3, color=32767, coeff=16 + AnimTasks.spawn("FlashAnimTagWithColor", 2, { 10005, 2, 3, 32767, 16 }, vm) + AnimTasks.update(vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 2, 10 do AnimTasks.update(vm) end + assert_eq(AnimTasks.activeCount(), 0, "flash task terminates after 3 cycles") +end) + +-- 11. Full Move Execution Verification for Phase 2 Moves +local phase2Moves = { + { id = 111, name = "MOVE_DEFENSE_CURL" }, + { id = 107, name = "MOVE_MINIMIZE" }, + { id = 110, name = "MOVE_WITHDRAW" }, + { id = 339, name = "MOVE_BULK_UP" }, + { id = 207, name = "MOVE_SWAGGER" }, + { id = 263, name = "MOVE_FACADE" }, + { id = 97, name = "MOVE_AGILITY" }, + { id = 151, name = "MOVE_ACID_ARMOR" }, + { id = 144, name = "MOVE_TRANSFORM" }, + { id = 254, name = "MOVE_STOCKPILE" }, + { id = 255, name = "MOVE_SPIT_UP" }, + { id = 256, name = "MOVE_SWALLOW" }, + { id = 96, name = "MOVE_MEDITATE" }, + { id = 37, name = "MOVE_THRASH" }, + { id = 148, name = "MOVE_FLASH" }, + { id = 267, name = "MOVE_CAMOUFLAGE" }, + { id = 345, name = "MOVE_MAGICAL_LEAF" }, + { id = 174, name = "MOVE_CURSE" }, + { id = 204, name = "MOVE_CHARM" }, + { id = 321, name = "MOVE_TICKLE" }, + { id = 343, name = "MOVE_COVET" }, + { id = 38, name = "MOVE_DOUBLE_EDGE" }, + { id = 63, name = "MOVE_HYPER_BEAM" }, + { id = 14, name = "MOVE_SWORDS_DANCE" }, + { id = 160, name = "MOVE_CONVERSION" }, + { id = 153, name = "MOVE_EXPLOSION" }, +} + +local okDs, Dataset = pcall(require, "src.core.game3.dataset") +local cache = okDs and Dataset.cache and Dataset.cache() or nil +local packSrc = cache and cache.read and cache:read("data/generated/gba/pokemon/battle_anims/pack.lua") +if not packSrc then + local f = io.open("data/generated/gba/pokemon/battle_anims/pack.lua", "r") + if f then packSrc = f:read("*a"); f:close() end +end + +if packSrc then + local chunk = loadstring and loadstring(packSrc) or load(packSrc) + local pack = chunk() + for _, m in ipairs(phase2Moves) do + test("Full Move Execution: " .. m.name, function() + local script = pack.moves[m.id] + if not script then + error("Move " .. m.name .. " (id " .. m.id .. ") not found in pack") + end + Anim.reset({ headless = true }) + AnimTasks.reset() + AnimSprites.reset() + local vm = AnimVm.new() + vm:setPack(pack) + vm:launch(script, { attackerSide = "player", isReversed = false }) + local maxTicks = 360 + local ticks = 0 + while vm:busy() and ticks < maxTicks do + ticks = ticks + 1 + vm:update(1 / 60) + end + assert_eq(vm:busy(), false, m.name .. " should finish cleanly") + -- Present state restored + local p = Anim.present("player") + assert_eq(p.sx, 1, m.name .. " sx restored") + assert_eq(p.sy, 1, m.name .. " sy restored") + assert_eq(p.rotation, 0, m.name .. " rotation restored") + assert_eq(p.oy, 0, m.name .. " oy restored") + end) + end +end + +print(string.format("\nPhase 2 Summary: %d passed, %d failed", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_battle_anims_phase3_test.lua b/tests/game3_battle_anims_phase3_test.lua new file mode 100644 index 00000000..4c83c5e4 --- /dev/null +++ b/tests/game3_battle_anims_phase3_test.lua @@ -0,0 +1,347 @@ +-- tests/game3_battle_anims_phase3_test.lua +-- Unit and integration tests for Phase 3: Elemental FX, Particle Generators & Wave Systems + +package.path = package.path .. ";./?.lua" + +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimCallbacks = require("src.core.game3.battle.anim_callbacks") +local AnimVm = require("src.core.game3.battle.anim_vm") +local Anim = require("src.core.game3.battle.anim") + +local passed = 0 +local failed = 0 + +local function test(name, fn) + AnimTasks.reset() + AnimSprites.reset() + Anim.reset({ headless = true }) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(" PASS: " .. name) + else + failed = failed + 1 + print(" FAIL: " .. name .. " -> " .. tostring(err)) + end +end + +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)), 2) + end +end + +local function make_vm(attackerSide) + attackerSide = attackerSide or "player" + local targetSide = (attackerSide == "player") and "enemy" or "player" + local vm = AnimVm.new() + vm._attackerSide = attackerSide + vm._targetSide = targetSide + return vm +end + +print("=== Phase 3: Elemental FX & Particle Generators Tests ===") + +-- 1. Water & Wave Callbacks +test("SmallDriftingBubbles - Q8.8 drift and 21-frame lifetime", function() + local spr = AnimSprites.acquire({ + x = 100, y = 80, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.SmallDriftingBubbles + }) + assert_eq(AnimSprites.activeCount(), 1) + for _ = 1, 20 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 1) + AnimSprites.update() + assert_eq(AnimSprites.activeCount(), 0, "should destroy at frame 21") +end) + +test("BubbleEffect - sine wave oscillation and burst", function() + local spr = AnimSprites.acquire({ + x = 100, y = 80, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.BubbleEffect + }) + AnimSprites.update() + assert_eq(spr.oy < 0, true, "bubble rises upward") + for _ = 2, 35 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "bubble bursts") +end) + +test("CreateSurfWave - surging wave with scanline traversal and collision", function() + local vm = make_vm("player") + local pTgt = Anim.present("enemy") + local t = AnimTasks.spawn("CreateSurfWave", 2, {}, vm) + assert_eq(AnimTasks.activeCount(), 1) + AnimTasks.update(vm) + assert_eq(type(t.draw), "function", "CreateSurfWave must attach a draw function") + for f = 2, 25 do + AnimTasks.update(vm) + AnimSprites.update() + assert_eq(t.data[10] < 0, true, "wave scrolls horizontally across arena") + assert_eq(t._alpha > 0, true, "alpha blend is active") + end + assert_eq(AnimTasks.activeCount(), 1) + for _ = 26, 50 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "wave task completes cleanly") + assert_eq(pTgt.ox, 0, "target ox restored") + assert_eq(pTgt.oy, 0, "target oy restored") +end) + +test("LoadSandstormBackground - swirling sandstorm background and dust streaks", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("LoadSandstormBackground", 2, {}, vm) + assert_eq(AnimTasks.activeCount(), 1) + AnimTasks.update(vm) + assert_eq(type(t.draw), "function", "LoadSandstormBackground must attach a draw function") + for _ = 2, 35 do + AnimTasks.update(vm) + end + assert_eq(AnimTasks.activeCount(), 0, "sandstorm finishes cleanly") +end) + +test("AnimTasks.draw - safely iterates and executes active task draw routines", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("CreateSurfWave", 2, {}, vm) + AnimTasks.update(vm) + -- Should execute without throw even if love.graphics is absent/headless + AnimTasks.draw(0, 999, vm) + assert_eq(t.active, true) +end) + +-- 2. Fire & Volcano Callbacks +test("FirePlume - flame pillar with upward velocity", function() + local spr = AnimSprites.acquire({ + x = 100, y = 100, z = AnimSprites.Z.FRONT, + _args = { 0, 0, 20, -3, 0 }, + callback = AnimCallbacks.FirePlume + }) + AnimSprites.update() + assert_eq(spr.oy < 0, true, "flame moves upward") + for _ = 2, 22 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "flame extinguishes") +end) + +test("FireSpiralOutward - spiral vortex expanding trajectory", function() + local spr = AnimSprites.acquire({ + x = 100, y = 100, z = AnimSprites.Z.FRONT, + _args = { 0, 0, 24, 0, 0 }, + callback = AnimCallbacks.FireSpiralOutward + }) + AnimSprites.update() + assert_eq(spr.ox ~= nil and spr.oy ~= nil, true) + for _ = 2, 26 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "spiral finishes") +end) + +test("EruptionLaunchRocks - volcanic ballistic rock launch", function() + local vm = make_vm("player") + AnimTasks.spawn("EruptionLaunchRocks", 2, {}, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 40 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "eruption completes") +end) + +-- 3. Electric & Energy FX +test("VoltTackleBolt - stepped lightning path", function() + local vm = make_vm("player") + AnimTasks.spawn("VoltTackleBolt", 2, { 0 }, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 30 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "volt tackle bolt reaches destination") +end) + +test("ShockWaveProgressingBolt - progressing electrical arcs", function() + local vm = make_vm("player") + AnimTasks.spawn("ShockWaveProgressingBolt", 2, {}, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 25 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "shock wave progresses and finishes") +end) + +test("SolarBeamBigOrb - expanding charge orb", function() + local spr = AnimSprites.acquire({ + x = 100, y = 80, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.SolarBeamBigOrb + }) + AnimSprites.update() + assert_eq(spr.w > 0, true) + for _ = 2, 26 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "charge orb completes") +end) + +test("WeatherBallDown - descending atmospheric projectile", function() + local spr = AnimSprites.acquire({ + x = 100, y = 20, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.WeatherBallDown + }) + AnimSprites.update() + assert_eq(spr.oy >= 0, true) + for _ = 2, 22 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "weather ball impacts") +end) + +-- 4. Ice, Ground & Acoustic FX +test("IceEffectParticle - falling crystals and shatter", function() + local spr = AnimSprites.acquire({ + x = 100, y = 20, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.IceEffectParticle + }) + AnimSprites.update() + assert_eq(spr.oy > 0, true, "falls downward") + for _ = 2, 22 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "ice crystal shatters") +end) + +test("FrozenIceCube - encasement in ice matrix", function() + local vm = make_vm("player") + local pTgt = Anim.present("enemy") + AnimTasks.spawn("FrozenIceCube", 2, {}, vm) + AnimTasks.update(vm) + assert_eq(pTgt.blendCoeff > 0, true, "ice shimmer active") + for _ = 2, 35 do + AnimTasks.update(vm) + end + assert_eq(AnimTasks.activeCount(), 0, "ice cube thaws") + assert_eq(pTgt.blendCoeff, 0, "blend restored") +end) + +test("Hail - falling hailstorm", function() + local vm = make_vm("player") + AnimTasks.spawn("Hail", 2, {}, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 35 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "hailstorm finishes") +end) + +test("DirtPlumeParticle - ground explosion with gravity", function() + local spr = AnimSprites.acquire({ + x = 100, y = 100, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.DirtPlumeParticle + }) + AnimSprites.update() + assert_eq(spr.oy < 0, true, "initial upward blast") + for _ = 2, 25 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "dirt falls back and clears") +end) + +test("WaveFromCenterOfTarget - expanding acoustic shockwave", function() + local spr = AnimSprites.acquire({ + x = 120, y = 80, z = AnimSprites.Z.FRONT, + callback = AnimCallbacks.WaveFromCenterOfTarget + }) + AnimSprites.update() + assert_eq(spr.w > 0, true) + for _ = 2, 22 do + AnimSprites.update() + end + assert_eq(AnimSprites.activeCount(), 0, "sound wave expands and dissipates") +end) + +test("AtmosphericFog - mist and spore clouds", function() + local vm = make_vm("player") + AnimTasks.spawn("MistBallFog", 2, { 20 }, vm) + assert_eq(AnimTasks.activeCount(), 1) + for _ = 1, 25 do + AnimTasks.update(vm) + AnimSprites.update() + end + assert_eq(AnimTasks.activeCount(), 0, "fog dissipates") +end) + +-- 5. Full Elemental Move Script Executions +local phase3Moves = { + { id = 57, name = "MOVE_SURF" }, + { id = 330, name = "MOVE_MUDDY_WATER" }, + { id = 56, name = "MOVE_HYDRO_PUMP" }, + { id = 61, name = "MOVE_BUBBLE_BEAM" }, + { id = 55, name = "MOVE_WATER_GUN" }, + { id = 352, name = "MOVE_WATER_PULSE" }, + { id = 126, name = "MOVE_FIRE_BLAST" }, + { id = 53, name = "MOVE_FLAMETHROWER" }, + { id = 83, name = "MOVE_FIRE_SPIN" }, + { id = 284, name = "MOVE_ERUPTION" }, + { id = 315, name = "MOVE_OVERHEAT" }, + { id = 87, name = "MOVE_THUNDER" }, + { id = 85, name = "MOVE_THUNDERBOLT" }, + { id = 351, name = "MOVE_SHOCK_WAVE" }, + { id = 342, name = "MOVE_VOLT_TACKLE" }, + { id = 76, name = "MOVE_SOLAR_BEAM" }, + { id = 311, name = "MOVE_WEATHER_BALL" }, + { id = 192, name = "MOVE_ZAP_CANNON" }, + { id = 59, name = "MOVE_BLIZZARD" }, + { id = 58, name = "MOVE_ICE_BEAM" }, + { id = 258, name = "MOVE_HAIL" }, + { id = 89, name = "MOVE_EARTHQUAKE" }, + { id = 157, name = "MOVE_ROCK_SLIDE" }, + { id = 47, name = "MOVE_SING" }, + { id = 304, name = "MOVE_HYPER_VOICE" }, + { id = 54, name = "MOVE_MIST" }, + { id = 114, name = "MOVE_HAZE" }, + { id = 147, name = "MOVE_SPORE" }, +} + +local okDs, Dataset = pcall(require, "src.core.game3.dataset") +local cache = okDs and Dataset.cache and Dataset.cache() or nil +local packSrc = cache and cache.read and cache:read("data/generated/gba/pokemon/battle_anims/pack.lua") +if not packSrc then + local f = io.open("data/generated/gba/pokemon/battle_anims/pack.lua", "r") + if f then packSrc = f:read("*a"); f:close() end +end + +local pack = (loadstring or load)(packSrc)() +local vm = AnimVm.new() +vm:setPack(pack) + +for _, m in ipairs(phase3Moves) do + if pack.moves[m.id] then + test("Full Move Execution: " .. m.name, function() + AnimTasks.reset() + AnimSprites.reset() + Anim.reset({ headless = true }) + vm:launch(pack.moves[m.id], { attackerSide = "player", isReversed = false }) + local maxFrames = 360 + local frames = 0 + while vm:busy() and frames < maxFrames do + frames = frames + 1 + vm:update() + end + assert_eq(vm:busy(), false, string.format("Move %s timed out after %d frames", m.name, frames)) + end) + end +end + +print(string.format("\nPhase 3 Summary: %d passed, %d failed\n", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_battle_anims_phase4_test.lua b/tests/game3_battle_anims_phase4_test.lua new file mode 100644 index 00000000..e11b1bc5 --- /dev/null +++ b/tests/game3_battle_anims_phase4_test.lua @@ -0,0 +1,399 @@ +-- tests/game3_battle_anims_phase4_test.lua +-- Unit and integration tests for Phase 4: Dynamic Backgrounds, Clones, Distortions, Spotlights, Substitute & Evaluators + +package.path = package.path .. ";./?.lua" + +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimCallbacks = require("src.core.game3.battle.anim_callbacks") +local AnimVm = require("src.core.game3.battle.anim_vm") +local Anim = require("src.core.game3.battle.anim") + +local passed = 0 +local failed = 0 + +local function test(name, fn) + AnimTasks.reset() + AnimSprites.reset() + Anim.reset({ headless = true }) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(" PASS: " .. name) + else + failed = failed + 1 + print(" FAIL: " .. name .. " -> " .. tostring(err)) + end +end + +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)), 2) + end +end + +local function assert_true(val, msg) + if not val then + error(msg or "assert_true failed", 2) + end +end + +local function make_vm(attackerSide) + attackerSide = attackerSide or "player" + local targetSide = (attackerSide == "player") and "enemy" or "player" + local vm = AnimVm.new() + vm._attackerSide = attackerSide + vm._targetSide = targetSide + return vm +end + +print("=== Phase 4: Dynamic Backgrounds, Clones, Distortions & Specialized Systems Tests ===") + +-- --------------------------------------------------------------------------- +-- 1. Dynamic Scrolling Backgrounds & High-Altitude Environments +-- --------------------------------------------------------------------------- + +test("MoveSkyUppercutBg - vertical ascending clouds background lifetime and draw hook", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("MoveSkyUppercutBg", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + AnimTasks.update(vm) + assert_eq(t.z, AnimSprites.Z.GLOBAL_BEHIND, "should layer on GLOBAL_BEHIND") + assert_true(type(t.draw) == "function", "should provide a draw callback") + + for f = 2, 36 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("MoveSeismicTossBg - accelerating descent space background", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("MoveSeismicTossBg", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + AnimTasks.update(vm) + assert_eq(t.z, AnimSprites.Z.GLOBAL_BEHIND, "should layer on GLOBAL_BEHIND") + + for f = 2, 40 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("PositionFissureBgOnBattler - ground abyss fissure", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("PositionFissureBgOnBattler", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + + for f = 1, 40 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("SetPsychicBackground - warping psychic concentric rings background", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("SetPsychicBackground", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + + for f = 1, 32 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("HeartsBackground - floating hearts background for Attract", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("HeartsBackground", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + + for f = 1, 36 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("ScaryFace - looming demonic phantom mask", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("ScaryFace", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + AnimTasks.update(vm) + assert_eq(t.z, AnimSprites.Z.MID_FIELD, "should layer on MID_FIELD") + + for f = 2, 32 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("CreateRaindrops - diagonal falling raindrop particles", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("CreateRaindrops", 2, {}, vm) + assert_true(t ~= nil and t.active, "task should be spawned") + + AnimTasks.update(vm) -- frame 1 generates particles + assert_eq(t.z, AnimSprites.Z.GLOBAL_FRONT, "should layer on GLOBAL_FRONT") + assert_true(t._particles ~= nil and #t._particles > 0, "should have generated particles") + + for f = 2, 36 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +-- --------------------------------------------------------------------------- +-- 2. Shadow Clones, Afterimages & Silhouette Transfers +-- --------------------------------------------------------------------------- + +test("NightShadeClone & NightmareClone - rising dark silhouette with glowing eyes", function() + local vm = make_vm("player") + local t1 = AnimTasks.spawn("NightShadeClone", 2, {}, vm) + assert_true(t1 ~= nil and t1.active, "NightShadeClone should spawn") + + for f = 1, 32 do + AnimTasks.update(vm) + assert_true(t1.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t1.active, "should destroy after duration") + + local t2 = AnimTasks.spawn("NightmareClone", 2, {}, vm) + assert_true(t2 ~= nil and t2.active, "NightmareClone alias should spawn") + for _ = 1, 33 do AnimTasks.update(vm) end + assert_true(not t2.active, "should destroy after duration") +end) + +test("DestinyBondWhiteShadow - floor white shadow", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("DestinyBondWhiteShadow", 2, {}, vm) + assert_true(t ~= nil and t.active, "DestinyBondWhiteShadow should spawn") + + for f = 1, 30 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("MementoShadow - shadow transfer from attacker to target", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("InitMementoShadow", 2, {}, vm) + assert_true(t ~= nil and t.active, "InitMementoShadow should spawn") + + for f = 1, 32 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("RolePlaySilhouette - target silhouette copy transfer", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("RolePlaySilhouette", 2, {}, vm) + assert_true(t ~= nil and t.active, "RolePlaySilhouette should spawn") + + for f = 1, 28 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +-- --------------------------------------------------------------------------- +-- 3. Screen Distortions, Spotlights & Combat FX +-- --------------------------------------------------------------------------- + +test("ScreenDistortionWobble (Extrasensory & Uproar) - custom screen tint wobble", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("ExtrasensoryDistortion", 2, {}, vm) + assert_true(t ~= nil and t.active, "ExtrasensoryDistortion should spawn") + + AnimTasks.update(vm) + local fx = Anim.screenEffect() + assert_true(fx ~= nil, "should apply screen effect during distortion") + assert_eq(fx.type, "custom_blend", "should use custom_blend effect") + + for _ = 2, 25 do AnimTasks.update(vm) end + assert_true(not t.active, "should destroy after duration") + assert_eq(Anim.screenEffect().type, "none", "should clear screen effect upon finish") +end) + +test("Spotlight - cone lighting on target", function() + local vm = make_vm("player") + local t = AnimTasks.spawn("CreateSpotlight", 2, {}, vm) + assert_true(t ~= nil and t.active, "CreateSpotlight should spawn") + AnimTasks.update(vm) + assert_eq(t.z, AnimSprites.Z.GLOBAL_FRONT, "should layer on GLOBAL_FRONT") + + for f = 2, 32 do + AnimTasks.update(vm) + assert_true(t.active, "should remain active at frame " .. f) + end + AnimTasks.update(vm) + assert_true(not t.active, "should destroy after duration") +end) + +test("MorningSunLightBeam & GlareEyeDots - descending sunbeams and crimson eyes", function() + local vm = make_vm("player") + local tSun = AnimTasks.spawn("MorningSunLightBeam", 2, {}, vm) + assert_true(tSun ~= nil and tSun.active, "MorningSunLightBeam should spawn") + for _ = 1, 33 do AnimTasks.update(vm) end + assert_true(not tSun.active, "MorningSunLightBeam should destroy after duration") + + local tGlare = AnimTasks.spawn("GlareEyeDots", 2, {}, vm) + assert_true(tGlare ~= nil and tGlare.active, "GlareEyeDots should spawn") + for _ = 1, 29 do AnimTasks.update(vm) end + assert_true(not tGlare.active, "GlareEyeDots should destroy after duration") +end) + +test("Generic combat FX tasks - ElectricCharging, DrillPeck, GrudgeFlames, BarrageBall", function() + local vm = make_vm("player") + local list = { + "ElectricChargingParticles", "DrillPeckHitSplats", "GrudgeFlames", + "BarrageBall", "StatusClearedEffect", "RapinSpinMonElevation", + "ImprisonOrbs", "SketchDrawMon", "SkillSwap", "Teleport", + "ConversionAlphaBlend", "RotateAuroraRingColors" + } + for _, name in ipairs(list) do + local t = AnimTasks.spawn(name, 2, {}, vm) + assert_true(t ~= nil and t.active, "Task " .. name .. " should spawn") + for _ = 1, 25 do AnimTasks.update(vm) end + assert_true(not t.active, "Task " .. name .. " should finish cleanly in duration") + end +end) + +-- --------------------------------------------------------------------------- +-- 4. Battler Movement & Substitute Doll Swap +-- --------------------------------------------------------------------------- + +test("DoubleTeam - horizontal jitter, alpha pulse and clean restoration", function() + local vm = make_vm("player") + local p = Anim.present("player") + p.ox = 0 + p.alpha = 1.0 + + local t = AnimTasks.spawn("DoubleTeam", 2, { 30 }, vm) + assert_true(t ~= nil and t.active, "DoubleTeam should spawn") + + AnimTasks.update(vm) + assert_true(p.ox ~= 0, "DoubleTeam should offset battler ox") + + for _ = 2, 31 do AnimTasks.update(vm) end + assert_true(not t.active, "DoubleTeam should finish after 30 frames") + assert_eq(p.ox, 0, "DoubleTeam must reset ox to 0") + assert_eq(p.alpha, 1.0, "DoubleTeam must reset alpha to 1.0") +end) + +test("MonToSubstitute - squash and stretch scaling and clean restoration", function() + local vm = make_vm("player") + local p = Anim.present("player") + p.sx = 1.0 + p.sy = 1.0 + + local t = AnimTasks.spawn("MonToSubstitute", 2, {}, vm) + assert_true(t ~= nil and t.active, "MonToSubstitute should spawn") + + for _ = 1, 8 do AnimTasks.update(vm) end + assert_true(p.sy < 1.0 and p.sx > 1.0, "should squash vertically and stretch horizontally") + + for _ = 9, 17 do AnimTasks.update(vm) end + assert_true(not t.active, "MonToSubstitute should finish in 16 frames") + assert_eq(p.sx, 1.0, "sx must restore to 1.0") + assert_eq(p.sy, 1.0, "sy must restore to 1.0") +end) + +test("AttackerPunchWithTrace - forward lunge and reset", function() + local vm = make_vm("player") + local p = Anim.present("player") + p.ox = 0 + + local t = AnimTasks.spawn("AttackerPunchWithTrace", 2, {}, vm) + assert_true(t ~= nil and t.active, "AttackerPunchWithTrace should spawn") + + AnimTasks.update(vm) + for _ = 2, 21 do AnimTasks.update(vm) end + assert_true(not t.active, "should finish in 20 frames") + assert_eq(p.ox, 0, "ox must reset to 0") +end) + +-- --------------------------------------------------------------------------- +-- 5. Metadata Evaluators (Immediate Query Tasks) +-- --------------------------------------------------------------------------- + +test("QueryStateTask evaluators - immediate resolution without blocking VM", function() + local vm = make_vm("player") + local evaluators = { + "GetRolloutCounter", "GetFuryCutterHitCount", "IsFuryCutterHitRight", + "GetReturnPowerLevel", "GetFrustrationPowerLevel", "GetSeismicTossDamageLevel", + "IsPowerOver99", "GetBattleTerrain", "GetWeather", "IsContest", + "IsTargetPlayerSide", "GetIsDoomDesireHitTurn", "IsHealingMove" + } + for _, ev in ipairs(evaluators) do + local t = AnimTasks.spawn(ev, 2, {}, vm) + assert_true(t ~= nil, "Evaluator " .. ev .. " should spawn") + AnimTasks.update(vm) + assert_true(not t.active, "Evaluator " .. ev .. " should immediately resolve") + end +end) + +-- --------------------------------------------------------------------------- +-- 6. Complex Script Execution & Integration +-- --------------------------------------------------------------------------- + +local f = io.open("data/generated/gba/pokemon/battle_anims/pack.lua", "r") +local packSrc = f and f:read("*a") +if f then f:close() end +local chunk = loadstring and loadstring(packSrc) or load(packSrc) +local pack = chunk() + +local function run_move_script(moveId, moveName) + test(string.format("%s (Move #%d) - bytecode script execution", moveName, moveId), function() + local script = pack.moves[moveId] + assert_true(script ~= nil, "move script must exist for ID " .. tostring(moveId)) + local vm = AnimVm.new() + vm:setPack(pack) + vm:launch(script, { attackerSide = "player", isReversed = false }) + assert_true(vm:busy(), "VM should start busy") + + local maxTicks = 360 + local ticks = 0 + while vm:busy() and ticks < maxTicks do + ticks = ticks + 1 + vm:update(1 / 60) + end + assert_true(not vm:busy(), string.format("move %d timed out after %d ticks", moveId, maxTicks)) + end) +end + +run_move_script(164, "Substitute") +run_move_script(104, "Double Team") +run_move_script(327, "Sky Uppercut") +run_move_script(69, "Seismic Toss") +run_move_script(90, "Fissure") +run_move_script(213, "Attract") +run_move_script(262, "Memento") +run_move_script(272, "Role Play") +run_move_script(326, "Extrasensory") +run_move_script(266, "Follow Me") + +print(string.format("\nPhase 4 Results: %d passed, %d failed", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_battle_anims_pret_parity_test.lua b/tests/game3_battle_anims_pret_parity_test.lua new file mode 100644 index 00000000..ddbc2ef5 --- /dev/null +++ b/tests/game3_battle_anims_pret_parity_test.lua @@ -0,0 +1,263 @@ +-- tests/game3_battle_anims_pret_parity_test.lua +-- Rigorous 1:1 pret (pokefirered) parity test suite for battle animation tasks, callbacks and move scripts + +package.path = package.path .. ";./?.lua" + +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimCallbacks = require("src.core.game3.battle.anim_callbacks") +local AnimVm = require("src.core.game3.battle.anim_vm") +local Anim = require("src.core.game3.battle.anim") + +local passed = 0 +local failed = 0 + +local function test(name, fn) + AnimTasks.reset() + AnimSprites.reset() + Anim.reset({ headless = true }) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(" PASS: " .. name) + else + failed = failed + 1 + print(" FAIL: " .. name .. " -> " .. tostring(err)) + end +end + +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)), 2) + end +end + +local function assert_true(val, msg) + if not val then + error(msg or "assert_true failed", 2) + end +end + +local function make_vm(attackerSide) + attackerSide = attackerSide or "player" + local targetSide = (attackerSide == "player") and "enemy" or "player" + local vm = AnimVm.new() + vm._attackerSide = attackerSide + vm._targetSide = targetSide + return vm +end + +print("=== Pret (pokefirered) 1:1 Parity Audit Tests ===") + +-- --------------------------------------------------------------------------- +-- 1. Registry Completeness against pokefirered/data/battle_anim_scripts.s +-- --------------------------------------------------------------------------- + +test("Visual Tasks Registry - all 213 pret visual tasks are registered", function() + local f = io.open("pokefirered/data/battle_anim_scripts.s", "r") + assert_true(f ~= nil, "pokefirered/data/battle_anim_scripts.s must exist") + local text = f:read("*a") + f:close() + + local missing = 0 + for task in text:gmatch("createvisualtask%s+([%w_]+)") do + local key = task:gsub("^AnimTask_", "") + local fn = AnimTasks.REGISTRY[task] or AnimTasks.REGISTRY[key] or AnimTasks.REGISTRY["AnimTask_" .. key] + if not fn then + print(" Missing pret task in REGISTRY: " .. task) + missing = missing + 1 + end + end + assert_eq(missing, 0, "All visual tasks from battle_anim_scripts.s must be registered") +end) + +-- --------------------------------------------------------------------------- +-- 2. Dig & Subterranean Mechanics +-- --------------------------------------------------------------------------- + +test("DigDownMovement - 3-step subterranean bounce and invisible toggle", function() + local vm = make_vm("player") + local p = Anim.present("player") + p.oy = 0 + p.invisible = false + + local t = AnimTasks.spawn("DigDownMovement", 2, { 0 }, vm) + assert_true(t ~= nil and t.active, "DigDownMovement should spawn") + + for _ = 1, 24 do AnimTasks.update(vm) end + assert_true(p.oy > 0, "should have descended downward into ground") + + AnimTasks.update(vm) + assert_true(not t.active, "DigDownMovement should complete in 24 frames") + assert_true(p.invisible, "Mon should be invisible while underground") +end) + +test("DigUpMovement - emergence from underground with coordinate recovery", function() + local vm = make_vm("player") + local p = Anim.present("player") + p.oy = 32 + p.invisible = true + + local t = AnimTasks.spawn("DigUpMovement", 2, { 1 }, vm) + assert_true(t ~= nil and t.active, "DigUpMovement should spawn") + + AnimTasks.update(vm) + assert_true(not p.invisible, "Mon becomes visible immediately upon rising") + + for _ = 2, 20 do AnimTasks.update(vm) end + AnimTasks.update(vm) + assert_true(not t.active, "DigUpMovement should complete in 20 frames") + assert_eq(p.oy, 0, "Mon oy must restore to 0 upon completion") +end) + +-- --------------------------------------------------------------------------- +-- 3. Skull Bash & Extreme Speed Mechanics +-- --------------------------------------------------------------------------- + +test("SkullBashPosition - side-aware backstep and forward rush", function() + local vmPlayer = make_vm("player") + local pPlayer = Anim.present("player") + pPlayer.ox = 0 + + -- Player backstep + local t1 = AnimTasks.spawn("SkullBashPosition", 2, { 0 }, vmPlayer) + for _ = 1, 17 do AnimTasks.update(vmPlayer) end + assert_true(pPlayer.ox < 0, "Player should step back (negative ox)") + + -- Enemy backstep + local vmEnemy = make_vm("enemy") + local pEnemy = Anim.present("enemy") + pEnemy.ox = 0 + local t2 = AnimTasks.spawn("SkullBashPosition", 2, { 0 }, vmEnemy) + for _ = 1, 17 do AnimTasks.update(vmEnemy) end + assert_true(pEnemy.ox > 0, "Enemy should step back (positive ox)") +end) + +test("ExtremeSpeedImpact & Reappear - rapid target shudder and 14-frame flicker", function() + local vm = make_vm("player") + local pTgt = Anim.present("enemy") + pTgt.ox = 0 + + local tImpact = AnimTasks.spawn("ExtremeSpeedImpact", 2, {}, vm) + AnimTasks.update(vm) + assert_true(pTgt.ox ~= 0, "Target should shudder during ExtremeSpeed impact") + + for _ = 2, 19 do AnimTasks.update(vm) end + assert_true(not tImpact.active, "ExtremeSpeedImpact should finish after 18 frames") + assert_eq(pTgt.ox, 0, "Target ox should restore to 0") + + local pAtk = Anim.present("player") + local tReappear = AnimTasks.spawn("ExtremeSpeedMonReappear", 2, {}, vm) + for _ = 1, 14 do AnimTasks.update(vm) end + AnimTasks.update(vm) + assert_true(not tReappear.active, "ExtremeSpeedMonReappear should finish in 14 frames") + assert_eq(pAtk.invisible, false, "Attacker must be visible after reappearing") +end) + +-- --------------------------------------------------------------------------- +-- 4. Specialized Combat FX Tasks +-- --------------------------------------------------------------------------- + +test("WaterSpoutLaunch & Rain - water geyser and falling cascade", function() + local vm = make_vm("player") + local tLaunch = AnimTasks.spawn("WaterSpoutLaunch", 2, {}, vm) + assert_true(tLaunch ~= nil and tLaunch.active, "WaterSpoutLaunch should spawn") + for _ = 1, 33 do AnimTasks.update(vm) end + assert_true(not tLaunch.active, "WaterSpoutLaunch should finish after 32 frames") + + local tRain = AnimTasks.spawn("WaterSpoutRain", 2, {}, vm) + assert_true(tRain ~= nil and tRain.active, "WaterSpoutRain should spawn") + AnimTasks.update(vm) + assert_true(tRain._particles ~= nil and #tRain._particles > 0, "WaterSpoutRain should generate particles") + for _ = 2, 37 do AnimTasks.update(vm) end + assert_true(not tRain.active, "WaterSpoutRain should finish after 36 frames") +end) + +test("DoomDesireLightBeam & AirCutterProjectile - celestial beam and razor crescent", function() + local vm = make_vm("player") + local tBeam = AnimTasks.spawn("DoomDesireLightBeam", 2, {}, vm) + assert_true(tBeam ~= nil and tBeam.active, "DoomDesireLightBeam should spawn") + for _ = 1, 37 do AnimTasks.update(vm) end + assert_true(not tBeam.active, "DoomDesireLightBeam should finish after 36 frames") + + local tCutter = AnimTasks.spawn("AirCutterProjectile", 2, {}, vm) + assert_true(tCutter ~= nil and tCutter.active, "AirCutterProjectile should spawn") + for _ = 1, 21 do AnimTasks.update(vm) end + assert_true(not tCutter.active, "AirCutterProjectile should finish after 20 frames") +end) + +test("StatsChange, FakeOut, GrowAndGrayscale, ShrinkTargetCopy", function() + local vm = make_vm("player") + local pAtk = Anim.present("player") + local pTgt = Anim.present("enemy") + + -- FakeOut + local tFake = AnimTasks.spawn("FakeOut", 2, {}, vm) + for _ = 1, 17 do AnimTasks.update(vm) end + assert_true(not tFake.active, "FakeOut should finish in 16 frames") + + -- StatsChange + local tStats = AnimTasks.spawn("StatsChange", 2, { 0 }, vm) + for _ = 1, 33 do AnimTasks.update(vm) end + assert_true(not tStats.active, "StatsChange should finish in 32 frames") + + -- GrowAndGrayscale + local tGrow = AnimTasks.spawn("GrowAndGrayscale", 2, {}, vm) + for _ = 1, 25 do AnimTasks.update(vm) end + assert_true(not tGrow.active, "GrowAndGrayscale should finish in 24 frames") + assert_eq(pAtk.sx, 1.0, "sx must restore to 1.0") + assert_eq(pAtk.grayscale, 0, "grayscale must restore to 0") + + -- ShrinkTargetCopy + local tShrink = AnimTasks.spawn("ShrinkTargetCopy", 2, {}, vm) + for _ = 1, 21 do AnimTasks.update(vm) end + assert_true(not tShrink.active, "ShrinkTargetCopy should finish in 20 frames") + assert_eq(pTgt.sx, 1.0, "sx must restore to 1.0") + assert_eq(pTgt.alpha, 1.0, "alpha must restore to 1.0") +end) + +-- --------------------------------------------------------------------------- +-- 5. Move Execution Parity for Complex Multi-turn & Special Moves +-- --------------------------------------------------------------------------- + +local f = io.open("data/generated/gba/pokemon/battle_anims/pack.lua", "r") +local packSrc = f and f:read("*a") +if f then f:close() end +local chunk = loadstring and loadstring(packSrc) or load(packSrc) +local pack = chunk() + +local function run_move(moveId, moveName) + test(string.format("%s (Move #%d) - full bytecode execution parity", moveName, moveId), function() + local script = pack.moves[moveId] + assert_true(script ~= nil, "move script must exist for ID " .. tostring(moveId)) + local vm = AnimVm.new() + vm:setPack(pack) + vm:launch(script, { attackerSide = "player", isReversed = false }) + assert_true(vm:busy(), "VM should start busy") + + local maxTicks = 360 + local ticks = 0 + while vm:busy() and ticks < maxTicks do + ticks = ticks + 1 + vm:update(1 / 60) + end + assert_true(not vm:busy(), string.format("move %d timed out after %d ticks", moveId, maxTicks)) + end) +end + +run_move(91, "Dig") +run_move(130, "Skull Bash") +run_move(245, "Extreme Speed") +run_move(252, "Fake Out") +run_move(323, "Water Spout") +run_move(353, "Doom Desire") +run_move(314, "Air Cutter") +run_move(257, "Heat Wave") +run_move(37, "Thrash") +run_move(76, "SolarBeam") +run_move(174, "Curse") + +print(string.format("\nPret Parity Results: %d passed, %d failed", passed, failed)) +if failed > 0 then + os.exit(1) +end diff --git a/tests/game3_berry_pouch_1to1_test.lua b/tests/game3_berry_pouch_1to1_test.lua new file mode 100644 index 00000000..3dbd723b --- /dev/null +++ b/tests/game3_berry_pouch_1to1_test.lua @@ -0,0 +1,128 @@ +-- Test suite for Berry Pouch 1:1 authentic layout and mechanics. + +local ItemsData = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local BerryPouch = require("src.ui.game3.berry_pouch") + +local function assert_eq(a, b, msg) + if a ~= b then + error(string.format("Assertion failed: %s (expected %s, got %s)", msg or "", tostring(b), tostring(a)), 2) + end +end + +local function run_tests() + print("[berry_pouch_1to1_test] Starting Berry Pouch 1:1 tests...") + + -- 1. Berry Identification and Numbering + assert_eq(ItemsData.isBerry(133), true, "Cheri Berry is berry") + assert_eq(ItemsData.berryNumber(133), 1, "Cheri Berry is #01") + assert_eq(ItemsData.isBerry(134), true, "Chesto Berry is berry") + assert_eq(ItemsData.berryNumber(134), 2, "Chesto Berry is #02") + assert_eq(ItemsData.isBerry(175), true, "Enigma Berry is berry") + assert_eq(ItemsData.berryNumber(175), 43, "Enigma Berry is #43") + assert_eq(ItemsData.isBerry(1), false, "Master Ball is not berry") + assert_eq(ItemsData.isBerry(289), false, "TM01 is not berry") + + -- 2. Bag Berry Storage & Max Cap (999) + local bag = Bag.new() + local okAdd, count = Bag.add(bag, 133, 500) + assert_eq(okAdd, true, "Add 500 Cheri Berries") + assert_eq(count, 500, "500 Cheri Berries added") + local okAdd2, count2 = Bag.add(bag, 133, 499) + assert_eq(okAdd2, true, "Add 499 Cheri Berries (total 999)") + local berries = Bag.listPocket(bag, "BERRY_POUCH") + assert_eq(#berries, 1, "1 berry in pocket") + assert_eq(berries[1].qty, 999, "Quantity is 999") + + -- 3. Berry Pouch Open & Wobble Timer + local session = { bag = bag, party = {} } + BerryPouch.show(session, bag) + assert_eq(BerryPouch.isOpen(), true, "Berry Pouch is open") + assert_eq(BerryPouch.wobbleTimer > 0, true, "Affine wobble starts active on open") + assert_eq(BerryPouch.cursor, 1, "Cursor initialized at 1") + assert_eq(BerryPouch.scroll, 0, "Scroll initialized at 0") + + -- 4. 7-Row List Menu & CLOSE option + -- Add 10 different berries to test scrolling past 7 rows + for id = 134, 143 do + Bag.add(bag, id, 1) + end + local rows = Bag.listPocket(bag, "BERRY_POUCH") + assert_eq(#rows, 11, "11 unique berries in pocket") + + -- Mock Input helper + local function makeInput(key) + return { + wasPressed = function(_, k) return k == key end, + isDown = function(_, k) return k == key end, + } + end + + -- Cursor starts at 1. Total items = 11 + 1 (CLOSE) = 12. + -- Press UP to wrap to CLOSE option (item 12) + BerryPouch.handleInput(makeInput("up")) + assert_eq(BerryPouch.cursor, 12, "Wrapping up navigates to CLOSE option") + assert_eq(BerryPouch.scroll, 5, "Scroll adjusted so row 12 is visible (max scroll = 12 - 7 = 5)") + + -- Press DOWN to wrap back to row 1 + BerryPouch.handleInput(makeInput("down")) + assert_eq(BerryPouch.cursor, 1, "Wrapping down navigates to row 1") + assert_eq(BerryPouch.scroll, 0, "Scroll resets to 0") + + -- 5. Context Action Menu + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.mode, "action", "A button on item opens context action menu") + assert_eq(BerryPouch.actionCursor, 1, "Action cursor starts at USE") + + -- Move action cursor down to TOSS (index 3) + BerryPouch.handleInput(makeInput("down")) -- GIVE (2) + assert_eq(BerryPouch.actionCursor, 2, "Action cursor is GIVE") + BerryPouch.handleInput(makeInput("down")) -- TOSS (3) + assert_eq(BerryPouch.actionCursor, 3, "Action cursor is TOSS") + + -- Press A on TOSS -> Quantity Select Mode (since Cheri Berry qty is 999 > 1) + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.mode, "toss_select", "Entering toss quantity selection mode") + assert_eq(BerryPouch.tossQty, 1, "Initial toss quantity is 1") + + -- Test D-Pad Wrap: Down from 1 should wrap to max stack size (999) + BerryPouch.handleInput(makeInput("down")) + assert_eq(BerryPouch.tossQty, 999, "Down on 1 wraps to max stack quantity 999") + + -- Test D-Pad Wrap: Up from 999 should wrap back to 1 + BerryPouch.handleInput(makeInput("up")) + assert_eq(BerryPouch.tossQty, 1, "Up on 999 wraps to 1") + + -- Increase to 5 + for _ = 1, 4 do + BerryPouch.handleInput(makeInput("up")) + end + assert_eq(BerryPouch.tossQty, 5, "Toss quantity is 5") + + -- Press A to confirm quantity -> YES/NO modal + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.mode, "toss_confirm", "Entered toss confirmation mode") + assert_eq(BerryPouch.yesNoCursor, 1, "YesNo cursor is YES") + + -- Press A on YES to execute toss + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.mode, "message", "Toss complete shows message modal") + assert_eq(BerryPouch.messageText:find("Threw away 5") ~= nil, true, "Message confirms 5 items thrown away") + + -- Verify Bag has 999 - 5 = 994 Cheri Berries + local cheriRow = Bag.listPocket(bag, "BERRY_POUCH")[1] + assert_eq(cheriRow.qty, 994, "Quantity correctly reduced in Bag") + + -- Press A to dismiss message and return to list + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.mode, "list", "Returned to list mode") + + -- 6. Close Berry Pouch via CLOSE option + BerryPouch.cursor = 12 + BerryPouch.handleInput(makeInput("a")) + assert_eq(BerryPouch.isOpen(), false, "A on CLOSE closes Berry Pouch") + + print("[berry_pouch_1to1_test] ALL TESTS PASSED!") +end + +run_tests() diff --git a/tests/game3_evolution_scene_test.lua b/tests/game3_evolution_scene_test.lua index b585a4ee..3a32dd32 100644 --- a/tests/game3_evolution_scene_test.lua +++ b/tests/game3_evolution_scene_test.lua @@ -237,7 +237,7 @@ do 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 mon = { species = 19, speciesId = 19, level = 20, hp = 40, maxHp = 40, moves = { 33, 39 } } local completed = false local EvoSeq = require("src.core.game3.battle.evo_seq") @@ -257,7 +257,7 @@ do end if EvolutionScene._state == "congrats" or EvolutionScene._state == "learn_moves" then local mockInput = { - wasPressed = function(_, k) return k == "a" or k == "b" end, + wasPressed = function(_, k) return k == "a" end, isDown = function() return false end, } EvolutionScene.handleInput(mockInput) diff --git a/tests/game3_hall_of_fame_test.lua b/tests/game3_hall_of_fame_test.lua new file mode 100644 index 00000000..7615b15c --- /dev/null +++ b/tests/game3_hall_of_fame_test.lua @@ -0,0 +1,135 @@ +#!/usr/bin/env luajit +-- FRLG Hall of Fame Induction Screen & Egg Skipping Unit 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 HallOfFame = require("src.ui.game3.hall_of_fame") +local Pokemon = require("src.core.game3.pokemon") +local Adapters = require("src.core.game3.scripting.adapters") +local Natives = require("src.core.game3.scripting.natives") +local Std = require("src.core.game3.scripting.stdscripts") + +print("=== [TEST 1] Hall of Fame Strict Egg Skipping & Mon Progression ===") +do + local party = { + { species = 6, name = "CHARIZARD", level = 55, otId = 12345, moves = { "FLAMETHROWER", "FLY", "SLASH", "FIRE_SPIN" } }, + { species = 412, isEgg = true, egg = true, name = "EGG", level = 1 }, -- Egg in slot 2 + { species = 25, name = "PIKACHU", level = 52, otId = 12345, moves = { "THUNDERBOLT", "QUICK_ATTACK", "THUNDER_WAVE", "SLAM" } }, + { species = 175, egg = true, name = "TOGEPI EGG", level = 1 }, -- Egg in slot 4 + { species = 9, name = "BLASTOISE", level = 54, otId = 12345, moves = { "SURF", "HYDRO_PUMP", "ICE_BEAM", "BITE" } }, + { species = 143, name = "SNORLAX", level = 53, otId = 12345, moves = { "BODY_SLAM", "REST", "HYPER_BEAM", "EARTHQUAKE" } }, + } + + local session = { + name = "RED", + trainerId = 12345, + playTimeHours = 14, + playTimeMinutes = 22, + playTimeSeconds = 35, + party = party, + flags = {}, + } + + local doneCalled = false + HallOfFame.start({ + session = session, + onDone = function() doneCalled = true end, + }) + + check(HallOfFame.isOpen() == true, "HallOfFame is open") + check(#HallOfFame._mons == 4, "Eligible mons count is exactly 4 (skipped 2 eggs)") + + -- Mon 1: Charizard + local m1 = HallOfFame.getCurrentMon() + check(m1 and m1.name == "CHARIZARD", "Mon 1 is CHARIZARD") + + local inpA = { + wasPressed = function(_, k) return k == "a" end, + isDown = function() return false end, + } + + -- Advance to Mon 2: Pikachu (skipping egg at slot 2) + HallOfFame.handleInput(inpA) + local m2 = HallOfFame.getCurrentMon() + check(m2 and m2.name == "PIKACHU", "Mon 2 is PIKACHU (skipped egg)") + + -- Advance to Mon 3: Blastoise (skipping egg at slot 4) + HallOfFame.handleInput(inpA) + local m3 = HallOfFame.getCurrentMon() + check(m3 and m3.name == "BLASTOISE", "Mon 3 is BLASTOISE (skipped egg)") + + -- Advance to Mon 4: Snorlax + HallOfFame.handleInput(inpA) + local m4 = HallOfFame.getCurrentMon() + check(m4 and m4.name == "SNORLAX", "Mon 4 is SNORLAX") + + -- Advance to Congratulations screen + HallOfFame.handleInput(inpA) + check(HallOfFame._phase == "congrats", "Advanced to CONGRATULATIONS phase") + + -- Advance from Congratulations to close and yield to credits + HallOfFame.handleInput(inpA) + check(HallOfFame.isOpen() == false, "HallOfFame closed cleanly") + check(doneCalled == true, "onDone credits yield callback executed") + + -- Verify Flag & Debut Timestamp Recording + check(session.flags[0x82C] == true, "FLAG_SYS_GAME_CLEAR (0x82C) is set") + check(session.game_cleared == true, "session.game_cleared is true") + check(session.hofDebutTime == "14:22:35", "session.hofDebutTime stamped correctly") + check(session.hofDebutHours == 14 and session.hofDebutMinutes == 22, "hofDebutHours and Minutes stamped") + check(#session.hallOfFameTeams == 1, "Hall of fame team record saved") + check(#session.hallOfFameTeams[1] == 4, "Saved team record contains exactly 4 non-egg mons") +end + +print("=== [TEST 2] Scripting Special Native EnterHallOfFame Integration ===") +do + local hofCalled = false + local session = { + flags = {}, + party = { { species = 1, name = "BULBASAUR", level = 50 } }, + playTimeHours = 5, + playTimeMinutes = 10, + } + + local adapters = Adapters.stub({ + session = session, + hallOfFame = function(done) + hofCalled = true + HallOfFame.start({ + session = session, + onDone = done, + }) + end, + }) + + local nativeFn = Natives.ALLOW["special:" .. Std.SPECIAL.EnterHallOfFame] + check(type(nativeFn) == "function", "Special 272 EnterHallOfFame handler exists") + + local ctx = { pc = 1, waiting = false } + local yielded = nativeFn(ctx, adapters) + check(yielded == true, "Special EnterHallOfFame yielded execution to HallOfFame") + check(hofCalled == true, "adapters.hallOfFame was invoked") + check(HallOfFame.isOpen() == true, "HallOfFame UI is open") + check(session.flags[0x82C] == true, "FLAG_SYS_GAME_CLEAR set by native") + + -- Close + HallOfFame.close() + check(HallOfFame.isOpen() == false, "HallOfFame closed") +end + +if failed > 0 then + print(string.format("\n[FAILED] %d test(s) failed", failed)) + os.exit(1) +else + print("\nALL HALL OF FAME TESTS PASSED CLEANLY!") +end diff --git a/tests/game3_item_use_party_test.lua b/tests/game3_item_use_party_test.lua index b70465e1..d52b80b2 100644 --- a/tests/game3_item_use_party_test.lua +++ b/tests/game3_item_use_party_test.lua @@ -501,6 +501,112 @@ do check(closed == true, "onClose callback invoked") end +print("=== [TEST 11] Rare Candy Free-Slot Learn Move Dialog Persistence Across Frames ===") +do + local bag = Bag.new() + Bag.add(bag, 68, 1) -- 1 Rare Candy + local party = { + { + species = 19, speciesId = 19, level = 6, -- Rattata Lv 6 -> 7 learns Quick Attack (98) + hp = 20, maxHp = 20, + moves = { 33 }, -- Tackle (1/4 slots) + } + } + 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 == 7, "Rattata leveled up to 7") + + -- Complete HP anim if active + 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") + + -- Advance to Page 2 + PartyMenu.handleInput(mockInputA) + check(PartyMenu._statGrowthPage == 2, "stat growth on page 2") + + -- Advance to Learn Move queue + PartyMenu.handleInput(mockInputA) + check(PartyMenu.mode == "message", "PartyMenu entered 'message' mode for learned move") + check(PartyMenu._messageText ~= nil and PartyMenu._messageText:find("learned", 1, true) ~= nil, + "PartyMenu showing learned move message text (got: " .. tostring(PartyMenu._messageText) .. ")") + + -- Simulate multiple update ticks (frames) + for _ = 1, 60 do + PartyMenu.update(1 / 60) + end + check(PartyMenu.mode == "message", "PartyMenu remained in 'message' mode across 60 update ticks") + check(PartyMenu._messageText ~= nil and PartyMenu._messageText:find("learned", 1, true) ~= nil, + "PartyMenu message text stayed on screen without being eaten") + + -- Now player presses A to dismiss + PartyMenu.handleInput(mockInputA) + check(PartyMenu.mode ~= "message", "Message dismissed after user presses A") + check(Pokemon.knowsMove(party[1], 98), "Rattata knows Quick Attack") + PartyMenu.close() +end + +print("=== [TEST 12] Bag Menu List-Mode SELECT Shortcut Quick-Register & Overwrite ===") +do + local BagMenu = require("src.ui.game3.bag_menu") + local bag = Bag.new() + Bag.add(bag, 361, 1) -- Town Map (Key Item 361, registrability = 1) + Bag.add(bag, 360, 1) -- Bicycle (Key Item 360, registrability = 1) + local session = { bag = bag, registeredItem = nil } + + BagMenu.show(session, { + bag = bag, + session = session, + pocket = "KEY_ITEMS", + }) + check(BagMenu.isOpen() == true, "BagMenu is open in KEY_ITEMS pocket") + check(BagMenu.currentPocket() == "KEY_ITEMS", "current pocket is KEY_ITEMS") + + local function press(btn) + local inp = { + wasPressed = function(_, k) return k == btn end, + isDown = function() return false end, + } + BagMenu.handleInput(inp) + end + + -- Cursor starts on item 1 (Bicycle or Town Map depending on alphabetical sort) + local rows = BagMenu.list() + check(#rows >= 2, "at least 2 key items in bag") + local item1 = rows[1].id + local item2 = rows[2].id + + -- 1. Press SELECT on item 1 -> registers item 1 + press("select") + check(session.registeredItem == item1, "item 1 registered via SELECT (" .. tostring(item1) .. ")") + + -- 2. Move cursor down to item 2 and press SELECT -> OVERWRITES to item 2 + press("down") + check(BagMenu.cursor == 2, "cursor moved to item 2") + press("select") + check(session.registeredItem == item2, "item 2 overwrote registered item (" .. tostring(item2) .. ")") + + -- 3. Press SELECT on item 2 again -> UNREGISTERS to nil + press("select") + check(session.registeredItem == nil, "pressing SELECT on already registered item unregisters to nil") + + BagMenu.close() +end + if failed > 0 then print(string.format("\n[FAILED] %d test(s) failed", failed)) os.exit(1) diff --git a/tests/game3_pokecenter_heal_test.lua b/tests/game3_pokecenter_heal_test.lua index 0f5a370f..58c6a708 100644 --- a/tests/game3_pokecenter_heal_test.lua +++ b/tests/game3_pokecenter_heal_test.lua @@ -57,3 +57,17 @@ end check("two balls", maxBalls == 2) check("done", done) print("pokecenter heal OAM parity ok (" .. frames .. " frames)") + +-- Test FieldEffects routing +local FieldEffects = require("src.core.game3.field_effects") +check("FieldEffects.doFieldEffect starts heal", FieldEffects.doFieldEffect(25) == true) +check("FieldEffects.isFieldEffectActive is true", FieldEffects.isFieldEffectActive(25) == true) +local fldDone = false +FieldEffects.waitFieldEffect(25, function() fldDone = true end) +for i = 1, 800 do + FieldEffects.step() + if fldDone then break end +end +check("FieldEffects.waitFieldEffect completed", fldDone == true) +check("FieldEffects.isFieldEffectActive is false after done", FieldEffects.isFieldEffectActive(25) == false) +print("FieldEffects integration ok") diff --git a/tests/game3_save_trainer_card_test.lua b/tests/game3_save_trainer_card_test.lua index b257aaa5..cff0545b 100644 --- a/tests/game3_save_trainer_card_test.lua +++ b/tests/game3_save_trainer_card_test.lua @@ -41,6 +41,26 @@ test("TrainerCard closed callback called", function() assert(closed == true, "onClose should have been called") end) +test("TrainerCard front and back flip", function() + TrainerCard.show({ session = session }) + assert(TrainerCard.isOpen() == true, "Should be open") + assert(TrainerCard.side == "front", "Initial side should be front") + + -- Press A to flip to back + local inpA = { wasPressed = function(_, k) return k == "a" end } + TrainerCard.handleInput(inpA) + assert(TrainerCard.side == "back", "Side should flip to back") + + -- Press A again to flip to front + TrainerCard.handleInput(inpA) + assert(TrainerCard.side == "front", "Side should flip back to front") + + -- Press B to close + local inpB = { wasPressed = function(_, k) return k == "b" end } + TrainerCard.handleInput(inpB) + assert(TrainerCard.isOpen() == false, "Should be closed after B") +end) + print("[test] 2. SaveMenu lifecycle and state machine") local SaveMenu = require("src.ui.game3.save_menu") @@ -73,7 +93,8 @@ test("SaveMenu confirm transitions to overwrite then saved", function() assert(saveClosed == true, "Save onClose callback should be called") end) -print("[test] 3. SaveMenu cancellation", function() +print("[test] 3. SaveMenu cancellation") +test("SaveMenu cancellation", function() local noClosed = false SaveMenu.show({ session = session, @@ -85,4 +106,100 @@ print("[test] 3. SaveMenu cancellation", function() assert(noClosed == true, "onClose should be called on cancel") end) +print("[test] 4. Trainer Card badge unlocking & multi-format resolution") +test("Badges from array format", function() + local s = { badges = { true, true, true, false, false, false, false, false } } + assert(TrainerCard.countBadges(s) == 3, "Should count 3 badges from array") + assert(TrainerCard.isBadgeUnlocked(1) == false, "Default closed should be false") + TrainerCard.show({ session = s }) + assert(TrainerCard.isBadgeUnlocked(1) == true, "Badge 1 unlocked") + assert(TrainerCard.isBadgeUnlocked(2) == true, "Badge 2 unlocked") + assert(TrainerCard.isBadgeUnlocked(3) == true, "Badge 3 unlocked") + assert(TrainerCard.isBadgeUnlocked(4) == false, "Badge 4 locked") + TrainerCard.close() +end) + +test("Badges from FRLG flags format (0x820..0x827)", function() + local s = { + flags = { + [0x820] = true, -- Boulder + [0x821] = true, -- Cascade + [0x823] = true, -- Rainbow + [0x826] = true, -- Volcano + } + } + assert(TrainerCard.countBadges(s) == 4, "Should count 4 badges from flags") + TrainerCard.show({ session = s }) + assert(TrainerCard.isBadgeUnlocked(1) == true, "Boulder badge unlocked (0x820)") + assert(TrainerCard.isBadgeUnlocked(2) == true, "Cascade badge unlocked (0x821)") + assert(TrainerCard.isBadgeUnlocked(3) == false, "Thunder badge locked (0x822)") + assert(TrainerCard.isBadgeUnlocked(4) == true, "Rainbow badge unlocked (0x823)") + assert(TrainerCard.isBadgeUnlocked(7) == true, "Volcano badge unlocked (0x826)") + assert(TrainerCard.isBadgeUnlocked(8) == false, "Earth badge locked (0x827)") + TrainerCard.close() +end) + +test("Badges from scripting store flags", function() + local s = { + store = { + flags = { + ["FLAG_BADGE01_GET"] = true, + ["FLAG_BADGE03_GET"] = true, + ["FLAG_BADGE05_GET"] = true, + } + } + } + assert(TrainerCard.countBadges(s) == 3, "Should count 3 badges from store.flags") + TrainerCard.show({ session = s }) + assert(TrainerCard.isBadgeUnlocked(1) == true, "Badge 1 (Boulder)") + assert(TrainerCard.isBadgeUnlocked(3) == true, "Badge 3 (Thunder)") + assert(TrainerCard.isBadgeUnlocked(5) == true, "Badge 5 (Soul)") + assert(TrainerCard.isBadgeUnlocked(2) == false, "Badge 2 (Cascade)") + TrainerCard.close() +end) + +test("Badges from bitmask format (0xFF = all 8 badges)", function() + local s = { badges = 0xFF } + assert(TrainerCard.countBadges(s) == 8, "Should count 8 badges from 0xFF bitmask") + TrainerCard.show({ session = s }) + for i = 1, 8 do + assert(TrainerCard.isBadgeUnlocked(i) == true, "Badge " .. i .. " unlocked") + end + TrainerCard.close() +end) + +print("[test] 5. Currency glyph mapping and font measure") +test("Currency sign maps to Pokédollar 0xB7", function() + local FrlgFont = require("src.ui.game3.frlg_font") + assert(FrlgFont.glyphId("$") == 0xB7, "$ must map to Pokédollar glyph 0xB7") + assert(FrlgFont.glyphId("¥") == 0xB7, "¥ must map to Pokédollar glyph 0xB7") + assert(FrlgFont.glyphId("\xC2\xA5") == 0xB7, "UTF-8 yen must map to Pokédollar glyph 0xB7") + local w1 = FrlgFont.measure("$3000") + local w2 = FrlgFont.measure("¥3000") + assert(w1 == w2, "Width of $3000 and ¥3000 must match") + assert(w1 > 0, "Width must be positive") +end) + +print("[test] 6. Runtime playtime accumulation and synchronization") +test("Runtime.pumpRtc increments and synchronizes playtime", function() + local Runtime = require("src.core.game3.runtime") + local sess = { + playtime = { hours = 1, minutes = 59, seconds = 58, vblanks = 0 } + } + local game = { session = sess, save = {} } + Runtime.session = sess + Runtime._playTimeAcc = 0 + + -- Tick 2 seconds (120 frames at 1/60s) + for _ = 1, 120 do + Runtime.pumpRtc(game, 1 / 60) + end + + assert(sess.playTimeHours == 2, "Hours should roll over to 2 (was " .. tostring(sess.playTimeHours) .. ")") + assert(sess.playTimeMinutes == 0, "Minutes should roll over to 0 (was " .. tostring(sess.playTimeMinutes) .. ")") + assert(sess.playTimeSeconds == 0, "Seconds should roll over to 0 (was " .. tostring(sess.playTimeSeconds) .. ")") + assert(game.save.playTimeHours == 2, "Save hours should synchronize to 2") + assert(game.save.playTimeMinutes == 0, "Save minutes should synchronize to 0") +end) + print("[test] all passed") diff --git a/tests/game3_step_events_queue_test.lua b/tests/game3_step_events_queue_test.lua new file mode 100644 index 00000000..d1e4b44f --- /dev/null +++ b/tests/game3_step_events_queue_test.lua @@ -0,0 +1,264 @@ +#!/usr/bin/env luajit +-- Comprehensive unit and integration test for: +-- 1. StepEvents Engine (lockstep queue, lethal poison, whiteout flush, repel, happiness, egg cycles, vs seeker charge) +-- 2. VS Seeker Engine (battery charge across movement modes, indoor rejection, outdoor ping, battery drain) +-- 3. TM Case & Berry Pouch Sub-Containers & Bag state persistence + +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 + +-- Mock Love2D globals if not present +if not _G.love then + _G.love = { + graphics = { + setColor = function() end, + rectangle = function() end, + circle = function() end, + arc = function() end, + draw = function() end, + newImage = function() return { setFilter = function() end } end, + newQuad = function() return {} end, + }, + timer = { getTime = function() return 0 end }, + filesystem = { read = function() return nil end }, + } +end + +local StepEvents = require("src.core.game3.step_events") +local VsSeeker = require("src.core.game3.vs_seeker") +local ItemsData = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local ItemUse = require("src.core.game3.item_use") +local TmCase = require("src.ui.game3.tm_case") +local BerryPouch = require("src.ui.game3.berry_pouch") +local BagMenu = require("src.ui.game3.bag_menu") +local Pokemon = require("src.core.game3.pokemon") + +print("=== 1. StepEvents Engine & Lockstep Queue Tests ===") + +StepEvents.flush() +check(not StepEvents.busy(), "Initial StepEvents is not busy") + +local eventOrder = {} +StepEvents.queueEvent(function() table.insert(eventOrder, "first") end) +StepEvents.queueEvent(function() table.insert(eventOrder, "second") end) +check(StepEvents.busy(), "StepEvents is busy with 2 queued events") + +StepEvents.update(0.1) +check(#eventOrder == 1 and eventOrder[1] == "first", "First event executed") +check(StepEvents.busy(), "StepEvents still busy with remaining event") + +StepEvents.update(0.1) +check(#eventOrder == 2 and eventOrder[2] == "second", "Second event executed") +check(not StepEvents.busy(), "Queue drained, StepEvents is no longer busy") + +print("=== 2. VS Seeker Battery Management across Locomotion Modes ===") + +local session = { + vsSeekerCharge = 0, + repelSteps = 10, + party = { + { species = 25, nickname = "PIKACHU", hp = 20, maxHp = 20, friendship = 70 }, + }, +} + +-- Step counting across locomotion modes +for i = 1, 50 do + StepEvents.onStep(session, { running = false, biking = false, surfing = false }) +end +check(VsSeeker.getBattery(session) == 50, "VS Seeker charged 50 steps via walking") + +for i = 1, 30 do + StepEvents.onStep(session, { running = true, biking = false, surfing = false }) +end +check(VsSeeker.getBattery(session) == 80, "VS Seeker charged +30 steps via running (total 80)") + +for i = 1, 20 do + StepEvents.onStep(session, { running = false, biking = true, surfing = false }) +end +check(VsSeeker.getBattery(session) == 100, "VS Seeker reached 100 charge via biking") + +-- Charging capped at 100 +StepEvents.onStep(session, { running = false, biking = false, surfing = true }) +check(VsSeeker.getBattery(session) == 100, "VS Seeker charge capped at 100") + +print("=== 3. Repel Countdown & Single Wear-off Mechanics ===") + +check(session.repelSteps == 0, "Repel wore off after 10 steps (started at 10)") +-- Verify repel queued wear off event +check(StepEvents.busy(), "Repel wear-off event queued") +local handledRepel = false +local mockHud = { + showDialogue = function(msg, opts) + if msg:find("Repel's effect wore off") then + handledRepel = true + end + if opts and opts.onDone then opts.onDone() end + end +} +package.loaded["src.ui.game3.hud"] = mockHud +StepEvents.update(0.1) +check(handledRepel, "Repel displayed retail 'Repel's effect wore off...' message without prompt") + +print("=== 4. Happiness & Egg Cycles Mechanics ===") + +local eggMon = { species = 175, nickname = "EGG", isEgg = true, eggCycles = 2 } +local partyMon = { species = 25, nickname = "PIKA", hp = 30, maxHp = 30, friendship = 100 } +session.party = { partyMon, eggMon } +StepEvents.flush() + +-- Step 128 times for friendship +for i = 1, 128 do + StepEvents.onStep(session, {}) +end +check(partyMon.friendship == 101, "Party mon gained +1 friendship after 128 steps") + +-- Step another 128 times (total 256 steps) for egg cycle decrement +for i = 1, 128 do + StepEvents.onStep(session, {}) +end +check(eggMon.eggCycles == 1, "Egg decremented 1 egg cycle after 256 steps") + +print("=== 5. Gen 3 Lethal Poison & Whiteout Queue Flush ===") + +StepEvents.flush() +local poisonedMon1 = { species = 1, nickname = "BULBA", hp = 2, maxHp = 20, status = "PSN" } +local poisonedMon2 = { species = 4, nickname = "CHAR", hp = 1, maxHp = 20, status = "PSN" } +session.party = { poisonedMon1, poisonedMon2 } + +-- 4 steps trigger 1 HP poison damage +for i = 1, 4 do + StepEvents.onStep(session, {}) +end +check(poisonedMon1.hp == 1, "Poisoned mon 1 took 1 HP damage (now 1 HP)") +check(poisonedMon2.hp == 0, "Poisoned mon 2 dropped to 0 HP and fainted") + +-- Process queued poison faint dialogue +local faintedMsg = false +mockHud.showDialogue = function(msg, opts) + if msg:find("CHAR fainted") or msg:find("fainted") then + faintedMsg = true + end + if opts and opts.onDone then opts.onDone() end +end +StepEvents.update(0.1) +check(faintedMsg, "Lethal poison faint event processed") + +-- Next 4 steps drop last Pokémon to 0 HP -> Whiteout +for i = 1, 4 do + StepEvents.onStep(session, {}) +end +check(poisonedMon1.hp == 0, "Poisoned mon 1 dropped to 0 HP (entire party fainted)") + +local whiteoutTriggered = false +session.onWhiteout = function() + whiteoutTriggered = true +end +StepEvents.update(0.1) +check(whiteoutTriggered, "Whiteout triggered and StepEvents queue cleanly flushed") +check(not StepEvents.busy(), "StepEvents queue completely empty after whiteout") + +print("=== 6. VS Seeker Outdoor Ping & Indoor Rejection ===") + +VsSeeker.setBattery(session, 100) +session.map = "VIRIDIAN_FOREST" -- Invalid indoor/cave map + +local indoorDenied = false +mockHud.showDialogue = function(msg, opts) + if msg:find("can't be used here") then + indoorDenied = true + end + if opts and opts.onDone then opts.onDone() end +end + +local okUse = VsSeeker.use(session, nil) +check(not okUse and indoorDenied, "VS Seeker rejected in indoor/forest map") +check(VsSeeker.getBattery(session) == 100, "Battery preserved at 100 on indoor rejection") + +-- Outdoor route with no trainers +session.map = "MAP_ROUTE_1" +local noResponseMsg = false +mockHud.showDialogue = function(msg, opts) + if msg:find("no response") then + noResponseMsg = true + end + if opts and opts.onDone then opts.onDone() end +end + +local okOutdoor = VsSeeker.use(session, nil) +check(not okOutdoor and noResponseMsg, "Outdoor ping returned 'There is no response...'") +check(VsSeeker.getBattery(session) == 0, "Battery drained to 0 on valid outdoor radar ping") + +print("=== 7. TM Case & Berry Pouch Sub-Containers & Bag State Persistence ===") + +local bag = Bag.new() +Bag.add(bag, 289, 1) -- TM01 +Bag.add(bag, 290, 2) -- TM02 +Bag.add(bag, 139, 5) -- ORAN BERRY +Bag.add(bag, 142, 3) -- SITRUS BERRY + +session.bag = bag +session.party = { + { species = 1, nickname = "BULBASAUR", hp = 20, maxHp = 20, moves = { 33, 45 } }, +} + +-- TM Case listing +TmCase.show(session, bag) +check(TmCase.isOpen(), "TmCase opened") +local tmList = TmCase.list() +check(#tmList == 2, "TmCase has 2 TMs listed") +TmCase.close() +check(not TmCase.isOpen(), "TmCase closed cleanly") + +-- Berry Pouch listing +BerryPouch.show(session, bag) +check(BerryPouch.isOpen(), "BerryPouch opened") +local berryList = BerryPouch.list() +check(#berryList == 2, "BerryPouch has 2 Berries listed") +BerryPouch.close() +check(not BerryPouch.isOpen(), "BerryPouch closed cleanly") + +-- BagMenu sub-container transition & state preservation +BagMenu.show(session, { bag = bag }) +BagMenu.pocketIdx = 2 -- KEY_ITEMS +BagMenu.cursor = 1 +BagMenu.scroll = 0 + +-- Simulate selecting TM Case from Key Items +BagMenu.mode = "action" +BagMenu.actionCursor = 1 -- USE +local mockRow = { id = ItemsData.ITEM_TM_CASE, name = "TM CASE" } +-- Mock rows in BagMenu +BagMenu.list = function() return { mockRow } end + +local mockInput = { + wasPressed = function(self, key) + return key == "a" + end +} +BagMenu.handleInput(mockInput) +check(TmCase.isOpen(), "Using TM CASE from BagMenu opens TmCase UI") + +-- Close TM Case and verify BagMenu state restored +TmCase.close() +check(BagMenu.pocketIdx == 2, "BagMenu pocketIdx preserved (2 = KEY_ITEMS)") +check(BagMenu.cursor == 1, "BagMenu cursor preserved") +check(BagMenu.mode == "list", "BagMenu mode returned to list") +BagMenu.close() + +print(string.format("\n==========================================")) +if failed == 0 then + print("ALL TESTS PASSED SUCCESSFULLY!") +else + print(string.format("TESTS FAILED WITH %d ERRORS", failed)) + os.exit(1) +end diff --git a/tests/game3_tm_case_berry_pouch_extract_test.lua b/tests/game3_tm_case_berry_pouch_extract_test.lua new file mode 100644 index 00000000..7294b275 --- /dev/null +++ b/tests/game3_tm_case_berry_pouch_extract_test.lua @@ -0,0 +1,101 @@ +#!/usr/bin/env luajit +-- Test TM Case and Berry Pouch ROM Chrome Extraction & Versions offsets. + +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 Versions = require("src.import.gba.versions") +local TmCaseExtract = require("src.import.gba.tm_case_extract") +local BerryPouchExtract = require("src.import.gba.berry_pouch_extract") + +print("=== 1. Versions Offsets Verification ===") +check(Versions.TM_CASE_BG_GFX == 0xE845D8, "Versions.TM_CASE_BG_GFX is 0xE845D8") +check(Versions.TM_CASE_MENU_TILEMAP == 0xE84A24, "Versions.TM_CASE_MENU_TILEMAP is 0xE84A24") +check(Versions.TM_CASE_BG_TILEMAP == 0xE84B70, "Versions.TM_CASE_BG_TILEMAP is 0xE84B70") +check(Versions.TM_CASE_MENU_MALE_PAL == 0xE84CB0, "Versions.TM_CASE_MENU_MALE_PAL is 0xE84CB0") +check(Versions.TM_CASE_MENU_FEMALE_PAL == 0xE84D20, "Versions.TM_CASE_MENU_FEMALE_PAL is 0xE84D20") +check(Versions.TM_CASE_DISC_GFX == 0xE84D90, "Versions.TM_CASE_DISC_GFX is 0xE84D90") +check(Versions.TM_CASE_DISC_TYPES1_PAL == 0xE84F20, "Versions.TM_CASE_DISC_TYPES1_PAL is 0xE84F20") +check(Versions.TM_CASE_DISC_TYPES2_PAL == 0xE85068, "Versions.TM_CASE_DISC_TYPES2_PAL is 0xE85068") +check(Versions.TM_CASE_HM_GFX == 0xE99138, "Versions.TM_CASE_HM_GFX is 0xE99138") + +check(Versions.BERRY_POUCH_SPRITE_GFX == 0xE8560C, "Versions.BERRY_POUCH_SPRITE_GFX is 0xE8560C") +check(Versions.BERRY_POUCH_BG_GFX == 0xE859D0, "Versions.BERRY_POUCH_BG_GFX is 0xE859D0") +check(Versions.BERRY_POUCH_BG_PAL == 0xE85BA4, "Versions.BERRY_POUCH_BG_PAL is 0xE85BA4") +check(Versions.BERRY_POUCH_BG_PAL_FEMALE == 0xE85BF4, "Versions.BERRY_POUCH_BG_PAL_FEMALE is 0xE85BF4") +check(Versions.BERRY_POUCH_SPRITE_PAL == 0xE85C1C, "Versions.BERRY_POUCH_SPRITE_PAL is 0xE85C1C") +check(Versions.BERRY_POUCH_BG_TILEMAP == 0xE85C44, "Versions.BERRY_POUCH_BG_TILEMAP is 0xE85C44") + +print("\n=== 2. Extraction from FireRed ROM ===") + +local romPath = "1636 - Pokemon Fire Red (U)(Squirrels).gba" +local f = io.open(romPath, "rb") +if not f then + print("[SKIP] ROM not found at " .. romPath) + os.exit(0) +end +local romData = f:read("*all") +f:close() + +local rom = { + get = function(self, i) + return romData:byte(i + 1) or 0 + end +} + +local memoryCache = { + files = {}, + write = function(self, path, bytes) + self.files[path] = bytes + return true + end, + exists = function(self, path) + return self.files[path] ~= nil + end, + read = function(self, path) + return self.files[path] + end, +} + +-- 1. TM Case Extraction +local tmRes = TmCaseExtract.run(rom, memoryCache, { cacheRoot = "data/test_gba" }) +check(tmRes and tmRes.width == 240 and tmRes.height == 160, "TmCaseExtract completed") +check(memoryCache:exists("data/test_gba/items/tm_case/bg_male.rgba"), "bg_male.rgba written") +check(#memoryCache:read("data/test_gba/items/tm_case/bg_male.rgba") == 240 * 160 * 4, "bg_male.rgba size is 240x160x4") +check(memoryCache:exists("data/test_gba/items/tm_case/bg_female.rgba"), "bg_female.rgba written") +check(#memoryCache:read("data/test_gba/items/tm_case/bg_female.rgba") == 240 * 160 * 4, "bg_female.rgba size is 240x160x4") +check(memoryCache:exists("data/test_gba/items/tm_case/disc_0.rgba"), "disc_0.rgba (Normal) written") +check(#memoryCache:read("data/test_gba/items/tm_case/disc_0.rgba") == 32 * 32 * 4, "disc_0.rgba size is 32x32x4") +check(memoryCache:exists("data/test_gba/items/tm_case/disc_16.rgba"), "disc_16.rgba (Dragon) written") +check(#memoryCache:read("data/test_gba/items/tm_case/disc_16.rgba") == 32 * 32 * 4, "disc_16.rgba size is 32x32x4") +check(memoryCache:exists("data/test_gba/items/tm_case/hm_icon.rgba"), "hm_icon.rgba written") +check(#memoryCache:read("data/test_gba/items/tm_case/hm_icon.rgba") == 16 * 12 * 4, "hm_icon.rgba size is 16x12x4") +check(TmCaseExtract.ready(memoryCache, "data/test_gba"), "TmCaseExtract.ready reports true") + +-- 2. Berry Pouch Extraction +local bpRes = BerryPouchExtract.run(rom, memoryCache, { cacheRoot = "data/test_gba" }) +check(bpRes and bpRes.width == 240 and bpRes.height == 160, "BerryPouchExtract completed") +check(memoryCache:exists("data/test_gba/items/berry_pouch/bg_male.rgba"), "Berry Pouch bg_male.rgba written") +check(#memoryCache:read("data/test_gba/items/berry_pouch/bg_male.rgba") == 240 * 160 * 4, "Berry Pouch bg_male.rgba size is 240x160x4") +check(memoryCache:exists("data/test_gba/items/berry_pouch/bg_female.rgba"), "Berry Pouch bg_female.rgba written") +check(#memoryCache:read("data/test_gba/items/berry_pouch/bg_female.rgba") == 240 * 160 * 4, "Berry Pouch bg_female.rgba size is 240x160x4") +check(memoryCache:exists("data/test_gba/items/berry_pouch/pouch.rgba"), "pouch.rgba written") +check(#memoryCache:read("data/test_gba/items/berry_pouch/pouch.rgba") == 64 * 64 * 4, "pouch.rgba size is 64x64x4") +check(BerryPouchExtract.ready(memoryCache, "data/test_gba"), "BerryPouchExtract.ready reports true") + +print(string.format("\n==========================================")) +if failed == 0 then + print("ALL EXTRACTION TESTS PASSED SUCCESSFULLY!") +else + print(string.format("EXTRACTION TESTS FAILED WITH %d ERRORS", failed)) + os.exit(1) +end diff --git a/tests/game3_town_map_test.lua b/tests/game3_town_map_test.lua new file mode 100644 index 00000000..e69b3b0c --- /dev/null +++ b/tests/game3_town_map_test.lua @@ -0,0 +1,111 @@ +#!/usr/bin/env luajit +-- Kanto Town Map & Region Map Unit 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 RegionExtract = require("src.import.gba.region_map_extract") +local RegionMap = require("src.ui.game3.region_map") +local ItemUse = require("src.core.game3.item_use") +local Bag = require("src.core.game3.bag") + +print("=== [TEST 1] Kanto Map Section Grid Resolution ===") +do + -- 1. Pallet Town (x=4, y=11) + local locPallet = RegionExtract.resolveLocation("PALLET_TOWN", nil) + check(locPallet.x == 4 and locPallet.y == 11, "Pallet Town resolved to (4, 11)") + check(locPallet.name == "PALLET TOWN", "Pallet Town name verified") + + -- 2. Viridian City (x=4, y=8) + local locViridian = RegionExtract.resolveLocation("VIRIDIAN_CITY", nil) + check(locViridian.x == 4 and locViridian.y == 8, "Viridian City resolved to (4, 8)") + check(locViridian.name == "VIRIDIAN CITY", "Viridian City name verified") + + -- 3. Pewter City (x=4, y=4) + local locPewter = RegionExtract.resolveLocation("PEWTER_CITY", nil) + check(locPewter.x == 4 and locPewter.y == 4, "Pewter City resolved to (4, 4)") + + -- 4. Cerulean City (x=14, y=3) + local locCerulean = RegionExtract.resolveLocation("CERULEAN_CITY", nil) + check(locCerulean.x == 14 and locCerulean.y == 3, "Cerulean City resolved to (14, 3)") + + -- 5. Sub-location / Building (Red's House -> Pallet Town coords) + local locHouse = RegionExtract.resolveLocation("REDS_HOUSE_1F", nil) + check(locHouse.x == 4 and locHouse.y == 11, "Red's House maps to Pallet Town coordinates") + + -- 6. Dungeon (Viridian Forest -> 4, 6) + local locForest = RegionExtract.resolveLocation("VIRIDIAN_FOREST", "MAPSEC_VIRIDIAN_FOREST") + check(locForest.x == 4 and locForest.y == 6, "Viridian Forest maps to (4, 6)") + check(locForest.name == "VIRIDIAN FOREST", "Viridian Forest name verified") +end + +print("=== [TEST 2] RegionMap Interactive Cursor Navigation & Landmark Updates ===") +do + local closed = false + local session = { map = "PALLET_TOWN", gender = 0 } + RegionMap.show({ + session = session, + onClose = function() closed = true end, + }) + + check(RegionMap.isOpen() == true, "RegionMap is open") + check(RegionMap.playerX == 4 and RegionMap.playerY == 11, "player location initialized at Pallet Town (4, 11)") + check(RegionMap.cursorX == 4 and RegionMap.cursorY == 11, "cursor location initialized at (4, 11)") + check(RegionMap.currentLocationName() == "PALLET TOWN", "initial landmark name is PALLET TOWN") + + local function press(btn) + local inp = { + wasPressed = function(_, k) return k == btn end, + isDown = function() return false end, + } + RegionMap.handleInput(inp) + end + + -- Move UP: (4, 11) -> (4, 10) (Route 1) + press("up") + check(RegionMap.cursorX == 4 and RegionMap.cursorY == 10, "cursor moved UP to (4, 10)") + check(RegionMap.currentLocationName() == "ROUTE 1", "landmark name updated to ROUTE 1") + + -- Move UP twice: (4, 10) -> (4, 9) -> (4, 8) (Viridian City) + press("up") + press("up") + check(RegionMap.cursorX == 4 and RegionMap.cursorY == 8, "cursor moved UP to (4, 8)") + check(RegionMap.currentLocationName() == "VIRIDIAN CITY", "landmark name updated to VIRIDIAN CITY") + + -- Press B to close + press("b") + check(RegionMap.isOpen() == false, "RegionMap closed on B button") + check(closed == true, "onClose callback executed") +end + +print("=== [TEST 3] Inventory TOWN_MAP Item Use Integration ===") +do + local bag = Bag.new() + Bag.add(bag, 361, 1) -- Town Map + local session = { map = "CELADON_CITY", bag = bag } + + local ok, reason, msg = ItemUse.useField(session, bag, 361, nil) + check(ok == true, "ItemUse.useField accepts TOWN_MAP (item 361)") + check(reason == "map", "useField returns 'map' reason") + check(RegionMap.isOpen() == true, "RegionMap opened via ItemUse") + check(RegionMap.playerX == 11 and RegionMap.playerY == 6, "player position placed at Celadon City (11, 6)") + check(RegionMap.currentLocationName() == "CELADON CITY", "Celadon City landmark active") + RegionMap.close() + check(RegionMap.isOpen() == false, "RegionMap closed cleanly") +end + +if failed > 0 then + print(string.format("\n[FAILED] %d test(s) failed", failed)) + os.exit(1) +else + print("\nALL TOWN MAP & REGION MAP TESTS PASSED CLEANLY!") +end