feat(game3): battle anim engine parity, sub-containers, town map, and party menu fixes

This commit is contained in:
1jamie
2026-09-13 20:07:53 -05:00
parent d615ad39ab
commit b67fb745fa
68 changed files with 11946 additions and 536 deletions
+1 -1
View File
@@ -17,4 +17,4 @@ android.enableJetifier=false
android.useAndroidX=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=gen1recomp (test)
app.name=gen1recomp (dev)
+1 -1
View File
@@ -1 +1 @@
{"mods":[],"schema_version":1}
{"schema_version":1,"mods":[]}
+25 -1
View File
@@ -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
+103 -53
View File
@@ -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
+96 -2
View File
@@ -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
+458
View File
@@ -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]
File diff suppressed because it is too large Load Diff
+166 -38
View File
@@ -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
+1 -36
View File
@@ -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
+24 -5
View File
@@ -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)
+5
View File
@@ -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
+29
View File
@@ -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
+21 -4
View File
@@ -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
+129 -1
View File
@@ -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)
+43 -12
View File
@@ -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]
+45 -8
View File
@@ -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
+100 -12
View File
@@ -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
+5 -2
View File
@@ -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)
+9
View File
@@ -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
+6 -1
View File
@@ -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
+5
View File
@@ -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"
+36 -18
View File
@@ -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)
+32 -16
View File
@@ -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")
+10 -1
View File
@@ -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,
+287
View File
@@ -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
+3 -2
View File
@@ -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
+110
View File
@@ -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
+70 -2
View File
@@ -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
+211
View File
@@ -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
+2 -4
View File
@@ -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 = {
+22
View File
@@ -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 = {
+10
View File
@@ -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 })
+235
View File
@@ -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
+323
View File
@@ -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
+9 -5
View File
@@ -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
+33 -2
View File
@@ -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)
+51 -5
View File
@@ -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()
+466
View File
@@ -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
+144
View File
@@ -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
+13 -2
View File
@@ -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
+7
View File
@@ -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
+244
View File
@@ -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
+7 -2
View File
@@ -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
+6 -9
View File
@@ -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
+21 -6
View File
@@ -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
+12 -2
View File
@@ -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 } })
+217 -98
View File
@@ -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
+6 -1
View File
@@ -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
+433
View File
@@ -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
+199
View File
@@ -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
+236 -35
View File
@@ -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
+55 -5
View File
@@ -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")
+32
View File
@@ -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)
+161
View File
@@ -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
+182
View File
@@ -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
+384
View File
@@ -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
+347
View File
@@ -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
+399
View File
@@ -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
@@ -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
+128
View File
@@ -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()
+2 -2
View File
@@ -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)
+135
View File
@@ -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
+106
View File
@@ -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)
+14
View File
@@ -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")
+118 -1
View File
@@ -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")
+264
View File
@@ -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
@@ -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
+111
View File
@@ -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