mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-01 15:41:29 -04:00
feat(game3): battle anim engine parity, sub-containers, town map, and party menu fixes
This commit is contained in:
@@ -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 @@
|
||||
{"mods":[],"schema_version":1}
|
||||
{"schema_version":1,"mods":[]}
|
||||
+25
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+4417
-141
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user