mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-29 15:08:50 -04:00
Merge pull request #2442 from 1Jamie/grandpas-garage
feat(game3): implement roaming legendary beasts, Seagallop ferry, poison sfx, battle trapping moves, field weather, and overworld fixes
This commit is contained in:
@@ -400,17 +400,6 @@ function Game3:_handleBootAction(action)
|
||||
local modsDiff = SaveData.modsDiff and SaveData.modsDiff(save, activeMods) or nil
|
||||
local session = Schema.fromSaveTable(save)
|
||||
Options.bind(session, self.options)
|
||||
local legacy = Profile.of(session.version).map.legacyPrefixes or {}
|
||||
local legacyMap = false
|
||||
if type(session.map) == "string" then
|
||||
for _, prefix in ipairs(legacy) do
|
||||
if session.map:sub(1, #prefix) == prefix then legacyMap = true break end
|
||||
end
|
||||
end
|
||||
if legacyMap then
|
||||
print("[game3] ignoring legacy Sevii save map " .. session.map)
|
||||
session = Schema.newGame({ gender = 0 })
|
||||
end
|
||||
self:adoptSave(session, not self._modSaveAdopted)
|
||||
self._modSaveAdopted = true
|
||||
self.sessionStartedAt = os.time()
|
||||
|
||||
@@ -196,6 +196,9 @@ Audio.LEGENDARY_BATTLE_SONGS = {
|
||||
[144] = { "battleLegend", 341 }, -- SPECIES_ARTICUNO
|
||||
[145] = { "battleLegend", 341 }, -- SPECIES_ZAPDOS
|
||||
[146] = { "battleLegend", 341 }, -- SPECIES_MOLTRES
|
||||
[243] = { "battleDeoxys", 339 }, -- SPECIES_RAIKOU
|
||||
[244] = { "battleDeoxys", 339 }, -- SPECIES_ENTEI
|
||||
[245] = { "battleDeoxys", 339 }, -- SPECIES_SUICUNE
|
||||
[249] = { "battleLegend", 341 }, -- SPECIES_LUGIA
|
||||
[250] = { "battleLegend", 341 }, -- SPECIES_HO_OH
|
||||
}
|
||||
|
||||
@@ -14,8 +14,17 @@ function Ai.loadPack(opts)
|
||||
if Ai._pack and not opts.force then return Ai._pack end
|
||||
Ai._packTried = true
|
||||
local rel = "data/generated/gba/battle_ai/pack.lua"
|
||||
local src = require("src.core.game3.dataset").cache():read(rel)
|
||||
if not src then error("battle AI: " .. rel .. " is missing from the cache") end
|
||||
local src
|
||||
local ok, cache = pcall(function() return require("src.core.game3.dataset").cache() end)
|
||||
if ok and cache and cache.read then
|
||||
src = cache:read(rel)
|
||||
end
|
||||
if not src then
|
||||
if opts.required then
|
||||
error("battle AI: " .. rel .. " is missing from the cache")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local t = assert(load(src, "@" .. rel, "t", {}))()
|
||||
if type(t) ~= "table" or type(t.table) ~= "table" or type(t.scripts) ~= "table" then
|
||||
error("battle AI: " .. rel .. " is not a script pack")
|
||||
@@ -115,8 +124,8 @@ end
|
||||
local function move_target_byte(mv)
|
||||
if move_num(mv) == 0 then return 0 end
|
||||
local Moves = require("src.core.game3.battle.moves")
|
||||
local m = Moves.get(mv)
|
||||
return tonumber(m and m.target) or 0
|
||||
local ok, m = pcall(Moves.get, mv)
|
||||
return tonumber(ok and m and m.target) or 0
|
||||
end
|
||||
|
||||
local function random_u16(rng)
|
||||
@@ -268,6 +277,16 @@ function choose_move_core(st, id, opts)
|
||||
local aiAction = 0
|
||||
if aiFlags ~= 0 and pack and pack.table and pack.scripts and target then
|
||||
aiAction = run_scripts(pack, aiFlags, st, b, target, userSide, targetSide, scores, simulatedRNG, rng)
|
||||
elseif st.roamer then
|
||||
-- data/battle_ai_scripts.s: BattleAI_Roaming checks if_can_escape
|
||||
local okCmd, Cmds = pcall(require, "src.core.game3.battle.ai_cmds")
|
||||
local canEscape = true
|
||||
if okCmd and Cmds and Cmds.canEscape then
|
||||
canEscape = Cmds.canEscape(b, target)
|
||||
end
|
||||
if canEscape then
|
||||
aiAction = 0x2
|
||||
end
|
||||
elseif st.safari then
|
||||
-- data/battle_ai_scripts.s:3242
|
||||
local okR, Rules = pcall(require, "src.core.game3.battle.rules")
|
||||
|
||||
@@ -163,11 +163,12 @@ end
|
||||
local function status2_bits(battler)
|
||||
if not battler then return 0 end
|
||||
local b = 0
|
||||
if battler.status2 then b = bit_or_local(b, tonumber(battler.status2) or 0) end
|
||||
if battler.confusionTurns and battler.confusionTurns > 0 then b = bit_or_local(b, STATUS2.CONFUSION) end
|
||||
if battler.focusEnergy or battler.expFocusEnergy then b = bit_or_local(b, STATUS2.FOCUS_ENERGY) end
|
||||
if (battler.substituteHP or 0) > 0 then b = bit_or_local(b, STATUS2.SUBSTITUTE) end
|
||||
if battler.wrapped or battler.trapped then b = bit_or_local(b, STATUS2.WRAPPED) end
|
||||
if battler.meanLook or battler.escapePrevention then b = bit_or_local(b, STATUS2.ESCAPE_PREVENTION) end
|
||||
if battler.wrapped or battler.trapped or battler.expWrapped then b = bit_or_local(b, STATUS2.WRAPPED) end
|
||||
if battler.meanLook or battler.escapePrevention or battler.expTrapped or battler.expTrappedBy then b = bit_or_local(b, STATUS2.ESCAPE_PREVENTION) end
|
||||
if battler.bideTurns then b = bit_or_local(b, STATUS2.BIDE) end
|
||||
if battler.recharge then b = bit_or_local(b, STATUS2.RECHARGE) end
|
||||
if battler.rage then b = bit_or_local(b, STATUS2.RAGE) end
|
||||
@@ -253,7 +254,15 @@ local function ability_of(battler)
|
||||
if battler.mon then
|
||||
a = a or battler.mon.ability or battler.mon.abilityId
|
||||
end
|
||||
return tonumber(a) or 0
|
||||
if type(a) == "number" then return a end
|
||||
if type(a) == "string" then
|
||||
local ok, Abilities = pcall(require, "src.core.game3.battle.abilities")
|
||||
if ok and Abilities and Abilities.id then
|
||||
local okId, id = pcall(Abilities.id, a)
|
||||
if okId and id then return id end
|
||||
end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function hp_percent(battler)
|
||||
@@ -932,6 +941,48 @@ function CMD.if_target_not_taunted(vm, op)
|
||||
branch(vm, op.target) -- always true (taunt unsupported)
|
||||
end
|
||||
|
||||
local function can_escape_check(user, target)
|
||||
if not user then return true end
|
||||
if user.meanLook or user.escapePrevention or user.expTrapped or user.expTrappedBy or (user.expTrapTurns or 0) > 0 or user.wrapped or user.expIngrain then
|
||||
return false
|
||||
end
|
||||
if target and not (target.fainted or (target.mon and (tonumber(target.mon.hp) or 0) <= 0)) then
|
||||
local tab = ability_of(target)
|
||||
local uab = ability_of(user)
|
||||
-- SHADOW_TAG: 23
|
||||
if tab == 23 and uab ~= 23 then
|
||||
return false
|
||||
end
|
||||
-- ARENA_TRAP: 71, LEVITATE: 26, FLYING: 2
|
||||
if tab == 71 and uab ~= 26 then
|
||||
local t1, t2 = mon_types(user)
|
||||
if t1 ~= Types.ID.FLYING and t2 ~= Types.ID.FLYING then
|
||||
return false
|
||||
end
|
||||
end
|
||||
-- MAGNET_PULL: 42, STEEL: 8
|
||||
if tab == 42 then
|
||||
local t1, t2 = mon_types(user)
|
||||
if t1 == Types.ID.STEEL or t2 == Types.ID.STEEL then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function CMD.if_can_escape(vm, op)
|
||||
if can_escape_check(vm.user, vm.target) then branch(vm, op.target) else next_ip(vm) end
|
||||
end
|
||||
|
||||
function CMD.if_cant_escape(vm, op)
|
||||
if not can_escape_check(vm.user, vm.target) then branch(vm, op.target) else next_ip(vm) end
|
||||
end
|
||||
|
||||
function AiCmds.canEscape(user, target)
|
||||
return can_escape_check(user, target)
|
||||
end
|
||||
|
||||
function AiCmds.dispatch(vm, op)
|
||||
if not op or not op.op then
|
||||
vm.done = true
|
||||
|
||||
@@ -2081,14 +2081,19 @@ function Engine.canSwitch(st, adapter, battler)
|
||||
if not battler then return true end
|
||||
if battler.expTrapped or battler.escapePrevention or (battler.expTrapTurns or 0) > 0 or battler.expIngrain then
|
||||
-- src/party_menu.c:5964
|
||||
return false, RomText.ascii("gText_PkmnCantSwitchOut", { stringVars = { adapter:displayName(battler) } })
|
||||
local name = (adapter and adapter.displayName and adapter:displayName(battler)) or "POKéMON"
|
||||
local ok, txt = pcall(RomText.ascii, "gText_PkmnCantSwitchOut", { stringVars = { name } })
|
||||
if ok and txt then return false, txt end
|
||||
return false, name .. " can't be switched out!"
|
||||
end
|
||||
local holder, ab = Abilities.escapeBlocker(adapter, battler)
|
||||
if holder then
|
||||
-- src/pokemon.c:6029
|
||||
return false, State.text(st, "gText_PkmnsXPreventsSwitching", {
|
||||
local ok, txt = pcall(State.text, st, "gText_PkmnsXPreventsSwitching", {
|
||||
buff1 = State.prefixedName(st, holder), lastAbility = Abilities.id(ab),
|
||||
})
|
||||
if ok and txt then return false, txt end
|
||||
return false, "Can't escape!"
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -2441,31 +2446,15 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
|
||||
move = enemyAct.move, slot = enemyAct.slot,
|
||||
battler = 1, kind = "move",
|
||||
}
|
||||
else
|
||||
if st.enemy then st.enemy.expTurnOrder = 2 end
|
||||
actions[#actions + 1] = enemy_meta_row()
|
||||
end
|
||||
st.turnOrder = { 0, 1 }
|
||||
st.turnActions = actions
|
||||
return actions, playerAct
|
||||
end
|
||||
|
||||
local pMove, pSlot = playerAct.move, playerAct.slot
|
||||
if enemyMeta then
|
||||
-- pokefirered/src/battle_main.c:3586
|
||||
pMove, pSlot = forced_move(st.player, pMove, pSlot)
|
||||
if st.enemy then st.enemy.expTurnOrder = 1 end
|
||||
if st.player then st.player.expTurnOrder = 2 end
|
||||
actions[1] = enemy_meta_row()
|
||||
actions[2] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
|
||||
local mv = Moves.get(pMove)
|
||||
st._focusPunchSetup = nil
|
||||
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not st.player.expLockedMove
|
||||
and not adapter:hasStatus(st.player, "SLP") then
|
||||
st._focusPunchSetup = { st.player }
|
||||
end
|
||||
st.turnOrder = { 1, 0 }
|
||||
st.turnActions = actions
|
||||
return actions, nil
|
||||
end
|
||||
local eMove, eSlot = enemyAct.move, enemyAct.slot
|
||||
local function forced(b, mv, slot)
|
||||
if not b then return mv, slot end
|
||||
if b.expLockedMove then return b.expLockedMove, slot end
|
||||
@@ -2478,8 +2467,31 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
|
||||
end
|
||||
return mv, slot
|
||||
end
|
||||
|
||||
local pMove, pSlot = playerAct.move, playerAct.slot
|
||||
if enemyMeta then
|
||||
-- pokefirered/src/battle_main.c:3586
|
||||
pMove, pSlot = forced(st.player, pMove, pSlot)
|
||||
if st.enemy then st.enemy.expTurnOrder = 1 end
|
||||
if st.player then st.player.expTurnOrder = 2 end
|
||||
actions[1] = enemy_meta_row()
|
||||
if pMove then
|
||||
actions[2] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
|
||||
local mv = Moves.get(pMove)
|
||||
st._focusPunchSetup = nil
|
||||
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not st.player.expLockedMove
|
||||
and not adapter:hasStatus(st.player, "SLP") then
|
||||
st._focusPunchSetup = { st.player }
|
||||
end
|
||||
end
|
||||
st.turnOrder = { 1, 0 }
|
||||
st.turnActions = actions
|
||||
return actions, nil
|
||||
end
|
||||
|
||||
local eMove, eSlot = enemyAct.move, enemyAct.slot
|
||||
pMove, pSlot = forced(st.player, pMove, pSlot)
|
||||
eMove, eSlot = forced(st.enemy, eMove, eSlot)
|
||||
if eMove then eMove, eSlot = forced(st.enemy, eMove, eSlot) end
|
||||
-- pokefirered/src/battle_main.c:2926
|
||||
st.randomTurnNumber = roll(adapter, 0, 0xFFFF)
|
||||
local pPri = Moves.priority(pMove)
|
||||
@@ -2505,22 +2517,37 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
|
||||
-- pokefirered/src/battle_main.c:3682
|
||||
local focus = {}
|
||||
for _, row in ipairs({ { st.player, pMove }, { st.enemy, eMove } }) do
|
||||
local mv = Moves.get(row[2])
|
||||
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not row[1].expLockedMove
|
||||
and not adapter:hasStatus(row[1], "SLP") then
|
||||
focus[#focus + 1] = row[1]
|
||||
if row[2] then
|
||||
local okM, mv = pcall(Moves.get, row[2])
|
||||
if okM and mv and tonumber(mv.effect) == E.FOCUS_PUNCH and not row[1].expLockedMove
|
||||
and not adapter:hasStatus(row[1], "SLP") then
|
||||
focus[#focus + 1] = row[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
st._focusPunchSetup = (#focus > 0) and focus or nil
|
||||
|
||||
local function enemy_turn_action()
|
||||
if enemyAct.kind == "move" then
|
||||
return { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
|
||||
else
|
||||
return enemy_meta_row()
|
||||
end
|
||||
end
|
||||
|
||||
local function player_turn_action()
|
||||
return { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
|
||||
end
|
||||
|
||||
if playerFirst then
|
||||
st.player.expTurnOrder, st.enemy.expTurnOrder = 1, 2
|
||||
actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
|
||||
actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
|
||||
actions[#actions + 1] = player_turn_action()
|
||||
actions[#actions + 1] = enemy_turn_action()
|
||||
st.turnOrder = { 0, 1 }
|
||||
else
|
||||
st.player.expTurnOrder, st.enemy.expTurnOrder = 2, 1
|
||||
actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
|
||||
actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
|
||||
actions[#actions + 1] = enemy_turn_action()
|
||||
actions[#actions + 1] = player_turn_action()
|
||||
st.turnOrder = { 1, 0 }
|
||||
end
|
||||
st.turnActions = actions
|
||||
|
||||
@@ -1554,6 +1554,68 @@ local function step_safari_enemy(act)
|
||||
Battle._phase = "animating"
|
||||
end
|
||||
|
||||
local function step_enemy_flee(act)
|
||||
local st, ad = Battle._st, Battle._adapter
|
||||
if not st or not st.enemy then return end
|
||||
if State.isFainted(st.enemy) then
|
||||
if not Battle._actions[Battle._actionI] then after_actions() end
|
||||
return
|
||||
end
|
||||
if ad:hasStatus(st.enemy, "SLP") or ad:hasStatus(st.enemy, "FRZ") then
|
||||
if not Battle._actions[Battle._actionI] then after_actions() end
|
||||
return
|
||||
end
|
||||
local canEscape = Engine.canSwitch(st, ad, st.enemy)
|
||||
if not canEscape then
|
||||
local mark = ad:eventMark()
|
||||
local prev = ad._say
|
||||
ad._say = function() end
|
||||
-- pokefirered/src/battle_main.c:4321, battle_message.c:909
|
||||
ad:sayText("STRINGID_ATTACKERCANTESCAPE", { atk = st.enemy })
|
||||
ad._say = prev
|
||||
local evs = ad:eventsSince(mark)
|
||||
if Battle._headless then
|
||||
for _, e in ipairs(evs) do
|
||||
if e.kind == "msg" then Ui.push(e.text) end
|
||||
end
|
||||
if not Battle._actions[Battle._actionI] then after_actions() end
|
||||
return
|
||||
end
|
||||
AnimSeq.beginEvents(evs, seq_push)
|
||||
Battle._phase = "animating"
|
||||
return
|
||||
end
|
||||
|
||||
local mark = ad:eventMark()
|
||||
local prev = ad._say
|
||||
ad._say = function() end
|
||||
-- pokefirered/src/battle_main.c:3819
|
||||
local monName = State.displayName(st.enemy) or "The wild Pokémon"
|
||||
ad:sayText("STRINGID_WILDPKMNFLED", { buff1 = monName })
|
||||
ad._say = prev
|
||||
local evs = ad:eventsSince(mark)
|
||||
pcall(function()
|
||||
local SE = require("src.core.game3.se_ids")
|
||||
if SE and SE.SE_FLEE then
|
||||
require("src.core.game3.audio").playSe(SE.SE_FLEE)
|
||||
end
|
||||
end)
|
||||
st.over = true
|
||||
st.result = "fled"
|
||||
st.endReason = "enemy_fled"
|
||||
if Battle._headless then
|
||||
for _, e in ipairs(evs) do
|
||||
if e.kind == "msg" then Ui.push(e.text) end
|
||||
end
|
||||
Battle._pendingEnd = "fled"
|
||||
Battle._phase = "ending"
|
||||
return
|
||||
end
|
||||
AnimSeq.beginEvents(evs, seq_push)
|
||||
Battle._pendingEnd = "fled"
|
||||
Battle._phase = "ending"
|
||||
end
|
||||
|
||||
local function step_action()
|
||||
local st = Battle._st
|
||||
local ad = Battle._adapter
|
||||
@@ -1785,6 +1847,9 @@ local function step_action()
|
||||
if st.safari and (act.kind == "watch" or act.kind == "run") then
|
||||
return step_safari_enemy(act)
|
||||
end
|
||||
if (act.kind == "run" or act.kind == "flee") and (act.battler == 1 or act.user == "enemy" or (type(act.user) == "table" and act.user.side == "enemy")) then
|
||||
return step_enemy_flee(act)
|
||||
end
|
||||
if act.meta then
|
||||
if not Battle._actions[Battle._actionI] then after_actions() end
|
||||
return
|
||||
|
||||
@@ -116,6 +116,7 @@ end
|
||||
|
||||
local function from_rom(numId)
|
||||
Moves.romReady()
|
||||
if not Moves._rom then return nil end
|
||||
local row = Moves._rom[numId]
|
||||
if not row then return nil end
|
||||
local cat = Types.isPhysical(row.type) and "physical" or "special"
|
||||
@@ -162,9 +163,34 @@ function Moves.displayName(moveId)
|
||||
return require("src.core.game3.pokemon").moveName(num or moveId)
|
||||
end
|
||||
|
||||
local PRIORITY_FALLBACK = {
|
||||
[98] = 1, -- QUICK_ATTACK
|
||||
[182] = 2, -- PROTECT
|
||||
[197] = 2, -- DETECT
|
||||
[183] = 1, -- MACH_PUNCH
|
||||
[245] = 2, -- EXTREMESPEED
|
||||
[252] = 1, -- FAKE_OUT
|
||||
[283] = 3, -- HELPING_HAND
|
||||
[264] = 4, -- MAGIC_COAT
|
||||
[268] = 4, -- SNATCH
|
||||
[279] = -3, -- REVENGE
|
||||
[263] = -3, -- FOCUS_PUNCH
|
||||
[233] = -5, -- VITAL_THROW
|
||||
[46] = -6, -- ROAR
|
||||
[18] = -6, -- WHIRLWIND
|
||||
[309] = -6, -- COUNTER
|
||||
[310] = -6, -- MIRROR_COAT
|
||||
}
|
||||
|
||||
function Moves.priority(moveId)
|
||||
local m = Moves.get(moveId)
|
||||
return tonumber(m and m.priority) or 0
|
||||
if not moveId or moveId == 0 or moveId == "" then return 0 end
|
||||
local ok, m = pcall(Moves.get, moveId)
|
||||
if ok and m and m.priority ~= nil then return tonumber(m.priority) or 0 end
|
||||
local num = tonumber(moveId)
|
||||
if not num and type(moveId) == "string" then
|
||||
num = Moves.numForName(Moves.normalizeId(moveId))
|
||||
end
|
||||
return (num and PRIORITY_FALLBACK[num]) or 0
|
||||
end
|
||||
|
||||
return Moves
|
||||
|
||||
@@ -389,13 +389,13 @@ function State.text(st, id, fill)
|
||||
return require("src.core.game3.battle.battle_text").get(id, fill)
|
||||
end
|
||||
|
||||
-- src/battle_message.c:2248
|
||||
function State.prefixedName(st, battler, name)
|
||||
name = name or State.displayName(battler)
|
||||
if battler and battler.side == "player" then return name end
|
||||
local RomText = require("src.core.game3.rom_text")
|
||||
local prefix = (st ~= nil and not st.wild) and "sText_FoePkmnPrefix" or "sText_WildPkmnPrefix"
|
||||
return RomText.plain(prefix) .. name
|
||||
local ok, pre = pcall(RomText.plain, prefix)
|
||||
return (ok and pre or (st ~= nil and not st.wild and "Foe " or "Wild ")) .. name
|
||||
end
|
||||
|
||||
function State.isFainted(battler)
|
||||
|
||||
@@ -307,6 +307,14 @@ function BattleBridge.start(mod, game, foe, opts)
|
||||
session.battleOutcome = Natives.outcome_to_code(result or "win")
|
||||
end
|
||||
writeback(session, battleParty, remap, result, save, opts)
|
||||
if opts.roamer or (foe and foe.roamer) then
|
||||
local okR, Roamer = pcall(require, "src.core.game3.roamer")
|
||||
if okR and Roamer and Roamer.onBattleEnd then
|
||||
local st = package.loaded["src.core.game3.battle"] and package.loaded["src.core.game3.battle"].getState and package.loaded["src.core.game3.battle"].getState()
|
||||
local enemyMon = (st and st.enemy and st.enemy.mon) or foe
|
||||
Roamer.onBattleEnd(session, enemyMon, result, st and st.endReason)
|
||||
end
|
||||
end
|
||||
-- pokefirered/src/battle_main.c:3861
|
||||
if ModRuntime.wants("battle.ended") then
|
||||
local B = package.loaded["src.core.game3.battle"]
|
||||
|
||||
@@ -513,6 +513,7 @@ end
|
||||
local ARRIVAL_FACING = {
|
||||
[MB_CAVE_DOOR] = "down",
|
||||
[MB_WARP_DOOR] = "down",
|
||||
[MB_LADDER] = "down",
|
||||
[MB_SOUTH_ARROW_WARP] = "up",
|
||||
[MB_NORTH_ARROW_WARP] = "down",
|
||||
[MB_WEST_ARROW_WARP] = "right",
|
||||
@@ -526,7 +527,6 @@ local ARRIVAL_FACING = {
|
||||
function Collision.arrivalFacing(destBeh, storedDir)
|
||||
local f = ARRIVAL_FACING[destBeh]
|
||||
if f then return f end
|
||||
if destBeh == MB_LADDER then return storedDir or "down" end
|
||||
return "down"
|
||||
end
|
||||
|
||||
|
||||
@@ -308,8 +308,10 @@ function Dataset.attachMidLayouts(maps, cache)
|
||||
if decoded then
|
||||
local pair = (info and info.pair) or def.pair
|
||||
def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair)
|
||||
if decoded.width and decoded.width > 0 then def.width = decoded.width end
|
||||
if decoded.height and decoded.height > 0 then def.height = decoded.height end
|
||||
local tw = decoded.trueWidth or decoded.width
|
||||
local th = decoded.trueHeight or decoded.height
|
||||
if tw and tw > 0 then def.width = tw end
|
||||
if th and th > 0 then def.height = th end
|
||||
if pair then def.pair = pair end
|
||||
attached = attached + 1
|
||||
end
|
||||
|
||||
@@ -330,6 +330,7 @@ end
|
||||
--- derived from the area's own encounter rate. Rates at 80+ get no grace period
|
||||
--- at all; below that the wait grows as the rate drops.
|
||||
function Encounters.mapBaseCooldown(terrain, rate)
|
||||
if terrain == "grass" or terrain == "cave" or terrain == "tall_grass" then terrain = "land" end
|
||||
if terrain ~= "land" and terrain ~= "water" then return COOLDOWN_NONE end
|
||||
if rate == nil then return COOLDOWN_NONE end
|
||||
rate = tonumber(rate) or 0
|
||||
@@ -523,6 +524,17 @@ local function roll_area(mapId, areaKey, weights, enterFromOther, fallbackRate)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- pokefirered/src/wild_encounter.c:645 TryStartRoamerEncounter
|
||||
local okR, Roamer = pcall(require, "src.core.game3.roamer")
|
||||
if okR and Roamer and Roamer.tryEncounter then
|
||||
local okRt, Runtime = pcall(require, "src.core.game3.runtime")
|
||||
local session = okRt and Runtime and Runtime.getSession and Runtime.getSession()
|
||||
local roamerEnc = Roamer.tryEncounter(session, mapId, areaKey)
|
||||
if roamerEnc then
|
||||
return roamerEnc
|
||||
end
|
||||
end
|
||||
|
||||
local entry = pick_slot(area.slots, weights)
|
||||
if type(entry) ~= "table" then
|
||||
-- pret banks here too: the rate test passed but TryGenerateWildMon found
|
||||
@@ -663,6 +675,8 @@ local function mod_encounter(enc)
|
||||
speciesId = id or Pokemon.speciesFromName(enc.species),
|
||||
level = enc.level,
|
||||
item = enc.item,
|
||||
roamer = enc.roamer,
|
||||
foe = enc.foe,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -675,7 +689,13 @@ local function engine_encounter(enc)
|
||||
end
|
||||
id = id or tonumber(enc.speciesId)
|
||||
if not id then return nil end
|
||||
return { species = id, level = tonumber(enc.level) or 5, item = enc.item }
|
||||
return {
|
||||
species = id,
|
||||
level = tonumber(enc.level) or 5,
|
||||
item = enc.item,
|
||||
roamer = enc.roamer,
|
||||
foe = enc.foe,
|
||||
}
|
||||
end
|
||||
|
||||
local function same_encounter(enc) return enc end
|
||||
|
||||
@@ -1081,6 +1081,14 @@ function FieldView.draw(game, canvasW, canvasH, opts)
|
||||
canvasH = canvasH or Display.H
|
||||
opts = opts or {}
|
||||
|
||||
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
|
||||
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
local mapId = currentMapId(game)
|
||||
local mapDef = resolveMapDef(game, mapId)
|
||||
if FieldView._flashMapId ~= mapId then
|
||||
@@ -1283,6 +1291,10 @@ function FieldView.draw(game, canvasW, canvasH, opts)
|
||||
if okSS and SSAnne and SSAnne.drawSmoke then
|
||||
SSAnne.drawSmoke(camX, camY)
|
||||
end
|
||||
local okW, FieldWeather = pcall(require, "src.core.game3.field_weather")
|
||||
if okW and FieldWeather and FieldWeather.draw then
|
||||
FieldWeather.draw(camX, camY, canvasW, canvasH)
|
||||
end
|
||||
end
|
||||
|
||||
drawFlashMask(canvasW, canvasH)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
-- Field Weather System (Fog, Shade, Rain)
|
||||
-- pokefirered/src/field_weather.c
|
||||
-- pokefirered/src/field_weather_effects.c
|
||||
|
||||
local Dataset = require("src.core.game3.dataset")
|
||||
local Weather = require("src.core.game3.weather")
|
||||
|
||||
local FieldWeather = {}
|
||||
|
||||
local W, H = 240, 160
|
||||
local CACHE_ROOT = "data/generated/gba/weather"
|
||||
|
||||
FieldWeather._current = Weather.NONE
|
||||
FieldWeather._assets = nil
|
||||
FieldWeather._fogScrollOffset = 0
|
||||
FieldWeather._fogScrollCounter = 0
|
||||
FieldWeather._rainSprites = {}
|
||||
FieldWeather._rainTimer = 0
|
||||
|
||||
local function loadAssets()
|
||||
if FieldWeather._assets then return FieldWeather._assets end
|
||||
local cache = Dataset.cache()
|
||||
if not (cache and cache.read) then return nil end
|
||||
local fogRaw = cache:read(CACHE_ROOT .. "/fog_horizontal.rgba")
|
||||
local rainRaw = cache:read(CACHE_ROOT .. "/rain.rgba")
|
||||
|
||||
local function imgFromRgba(raw, w, h)
|
||||
if not raw or #raw < w * h * 4 then return nil end
|
||||
local imgData = love.image.newImageData(w, h)
|
||||
local ptr = 1
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r = raw:byte(ptr) or 0
|
||||
local g = raw:byte(ptr + 1) or 0
|
||||
local b = raw:byte(ptr + 2) or 0
|
||||
local a = raw:byte(ptr + 3) or 0
|
||||
imgData:setPixel(x, y, r / 255, g / 255, b / 255, a / 255)
|
||||
ptr = ptr + 4
|
||||
end
|
||||
end
|
||||
local img = love.graphics.newImage(imgData)
|
||||
img:setFilter("nearest", "nearest")
|
||||
img:setWrap("repeat", "repeat")
|
||||
return img
|
||||
end
|
||||
|
||||
local fogImg = fogRaw and imgFromRgba(fogRaw, 64, 64)
|
||||
local rainImg = rainRaw and imgFromRgba(rainRaw, 16, 192)
|
||||
|
||||
FieldWeather._assets = {
|
||||
fog = fogImg,
|
||||
rain = rainImg,
|
||||
}
|
||||
return FieldWeather._assets
|
||||
end
|
||||
|
||||
function FieldWeather.setWeather(weatherId)
|
||||
FieldWeather._current = tonumber(weatherId) or Weather.NONE
|
||||
if FieldWeather._current == Weather.FOG_HORIZONTAL then
|
||||
FieldWeather._fogScrollOffset = 0
|
||||
FieldWeather._fogScrollCounter = 0
|
||||
elseif FieldWeather._current == Weather.RAIN or FieldWeather._current == Weather.RAIN_THUNDERSTORM then
|
||||
FieldWeather._rainSprites = {}
|
||||
FieldWeather._rainTimer = 0
|
||||
end
|
||||
end
|
||||
|
||||
function FieldWeather.getWeather()
|
||||
return FieldWeather._current
|
||||
end
|
||||
|
||||
function FieldWeather.update(dt)
|
||||
if Weather.isSuspended() then return end
|
||||
local w = FieldWeather._current
|
||||
|
||||
-- pokefirered/src/field_weather_effects.c:1332 FogHorizontal_Main
|
||||
if w == Weather.FOG_HORIZONTAL then
|
||||
FieldWeather._fogScrollCounter = FieldWeather._fogScrollCounter + 1
|
||||
if FieldWeather._fogScrollCounter > 3 then
|
||||
FieldWeather._fogScrollCounter = 0
|
||||
FieldWeather._fogScrollOffset = (FieldWeather._fogScrollOffset + 1) % 256
|
||||
end
|
||||
elseif w == Weather.RAIN or w == Weather.RAIN_THUNDERSTORM or w == Weather.DOWNPOUR then
|
||||
-- Rain particle generation & simulation
|
||||
FieldWeather._rainTimer = FieldWeather._rainTimer + (dt or (1 / 60))
|
||||
if #FieldWeather._rainSprites < 24 and math.random() < 0.35 then
|
||||
table.insert(FieldWeather._rainSprites, {
|
||||
x = math.random(-20, W + 20),
|
||||
y = math.random(-40, -10),
|
||||
speed = math.random(7, 10),
|
||||
groundY = math.random(20, H),
|
||||
splashTick = 0,
|
||||
state = "falling",
|
||||
})
|
||||
end
|
||||
|
||||
local i = 1
|
||||
while i <= #FieldWeather._rainSprites do
|
||||
local r = FieldWeather._rainSprites[i]
|
||||
if r.state == "falling" then
|
||||
r.x = r.x - 2
|
||||
r.y = r.y + r.speed
|
||||
if r.y >= r.groundY then
|
||||
r.state = "splash"
|
||||
r.splashTick = 0
|
||||
end
|
||||
elseif r.state == "splash" then
|
||||
r.splashTick = r.splashTick + 1
|
||||
if r.splashTick >= 6 then
|
||||
table.remove(FieldWeather._rainSprites, i)
|
||||
i = i - 1
|
||||
end
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render weather atmospheric layer over the field (before UI/dialogues)
|
||||
function FieldWeather.draw(camX, camY, canvasW, canvasH)
|
||||
if Weather.isSuspended() then return end
|
||||
local w = FieldWeather._current
|
||||
if w == Weather.NONE or w == Weather.SUNNY then return end
|
||||
|
||||
canvasW = canvasW or W
|
||||
canvasH = canvasH or H
|
||||
camX = camX or 0
|
||||
camY = camY or 0
|
||||
|
||||
local assets = loadAssets()
|
||||
|
||||
-- 1. WEATHER_SHADE: Atmospheric gamma dimming without color washout
|
||||
-- pokefirered/src/field_weather_effects.c:2172 Shade_InitVars (gammaTargetIndex = 3)
|
||||
if w == Weather.SHADE then
|
||||
love.graphics.push("all")
|
||||
love.graphics.setBlendMode("multiply", "premultiplied")
|
||||
-- Dim factor ~70% (0.70, 0.70, 0.75) for authentic cool shadow tint
|
||||
love.graphics.setColor(0.68, 0.68, 0.74, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- 2. WEATHER_FOG_HORIZONTAL: 64x64 fog sprites with GBA BLDALPHA (12/16 sprite, 8/16 backdrop)
|
||||
-- pokefirered/src/field_weather_effects.c:1343 Weather_SetTargetBlendCoeffs(12, 8, 3)
|
||||
if w == Weather.FOG_HORIZONTAL then
|
||||
love.graphics.push("all")
|
||||
love.graphics.setBlendMode("alpha")
|
||||
-- Alpha blend weight: 12/16 = 0.75
|
||||
love.graphics.setColor(1, 1, 1, 0.70)
|
||||
|
||||
if assets and assets.fog then
|
||||
-- pokefirered/src/field_weather_effects.c:1332 FogHorizontal_Update
|
||||
-- In vanilla FireRed, fog sprites are fixed in screen space and drift horizontally
|
||||
-- (+1 px every 4 frames) with zero camera/map parallax.
|
||||
local scrollX = FieldWeather._fogScrollOffset % 64
|
||||
local startX = -scrollX
|
||||
|
||||
for py = 0, canvasH, 64 do
|
||||
for px = startX, canvasW + 64, 64 do
|
||||
love.graphics.draw(assets.fog, px, py)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Procedural atmospheric horizontal fog bands fallback
|
||||
for py = 0, canvasH, 16 do
|
||||
local offset = (FieldWeather._fogScrollOffset * 2 + py * 4) % 64
|
||||
love.graphics.setColor(0.92, 0.95, 1.0, 0.28)
|
||||
love.graphics.rectangle("fill", -offset, py, canvasW + 64, 12)
|
||||
end
|
||||
end
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- 3. WEATHER_RAIN: Falling raindrops and splash particles
|
||||
if w == Weather.RAIN or w == Weather.RAIN_THUNDERSTORM or w == Weather.DOWNPOUR then
|
||||
love.graphics.push("all")
|
||||
love.graphics.setBlendMode("alpha")
|
||||
|
||||
for _, r in ipairs(FieldWeather._rainSprites) do
|
||||
if r.state == "falling" then
|
||||
love.graphics.setColor(0.75, 0.85, 1.0, 0.85)
|
||||
love.graphics.line(r.x, r.y, r.x - 2, r.y + 7)
|
||||
elseif r.state == "splash" then
|
||||
love.graphics.setColor(0.85, 0.92, 1.0, 0.65 - (r.splashTick * 0.1))
|
||||
love.graphics.circle("line", r.x, r.groundY, r.splashTick + 1)
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return FieldWeather
|
||||
@@ -33,12 +33,14 @@ function LayoutNative:cellAt(cx, cy)
|
||||
local key = cy * 1024 + cx
|
||||
local ov = self.overrides[key]
|
||||
if ov then return ov end
|
||||
if cx >= 0 and cy >= 0 and cx < self.width and cy < self.height then
|
||||
local tw = self.trueWidth or self.width
|
||||
local th = self.trueHeight or self.height
|
||||
if cx >= 0 and cy >= 0 and cx < tw and cy < th then
|
||||
return self.cells[cy * self.width + cx + 1]
|
||||
or { mid = 0, coll = 0xff, elev = 0 }
|
||||
end
|
||||
local bx, by = wrap_border(
|
||||
cx, cy, self.width, self.height, self.borderWidth, self.borderHeight)
|
||||
cx, cy, tw, th, self.borderWidth, self.borderHeight)
|
||||
local mid = self.borderMids[by * self.borderWidth + bx + 1] or 0
|
||||
return { mid = mid, coll = 0xff, elev = 0 }
|
||||
end
|
||||
@@ -57,15 +59,24 @@ end
|
||||
|
||||
--- Flat 1-based COLL_* array for Collision.bindMap.
|
||||
function LayoutNative:collArray()
|
||||
local n = self.width * self.height
|
||||
local w = self.width
|
||||
local h = self.height
|
||||
local tw = self.trueWidth or w
|
||||
local th = self.trueHeight or h
|
||||
local n = w * h
|
||||
if n <= 0 then return nil end
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
local cx = (i - 1) % self.width
|
||||
local cy = math.floor((i - 1) / self.width)
|
||||
local ov = self.overrides[cy * 1024 + cx]
|
||||
local c = self.cells[i]
|
||||
out[i] = (ov and ov.coll) or (c and c.coll) or 0xff
|
||||
for cy = 0, h - 1 do
|
||||
for cx = 0, w - 1 do
|
||||
local i = cy * w + cx + 1
|
||||
if cx < tw and cy < th then
|
||||
local ov = self.overrides[cy * 1024 + cx]
|
||||
local c = self.cells[i]
|
||||
out[i] = (ov and ov.coll) or (c and c.coll) or 0xff
|
||||
else
|
||||
out[i] = 0xff
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -276,6 +276,20 @@ function Map.load(mod, game, mapId, opts)
|
||||
if okE and Encounters and Encounters.resetRateModifiers then
|
||||
Encounters.resetRateModifiers()
|
||||
end
|
||||
local okR, Roamer = pcall(require, "src.core.game3.roamer")
|
||||
if okR and Roamer and Roamer.move then
|
||||
local okRt, Runtime = pcall(require, "src.core.game3.runtime")
|
||||
local session = okRt and Runtime and Runtime.getSession and Runtime.getSession()
|
||||
if session and session.roamer and session.roamer.active then
|
||||
local fromMapId = Map._announced
|
||||
if fromMapId ~= nil and fromMapId ~= mapId then
|
||||
local moveReason = (opts.teleport or opts.fly or opts.whiteout) and "warp_random" or "map_transition"
|
||||
Roamer.move(session, moveReason)
|
||||
elseif opts.teleport or opts.fly or opts.whiteout then
|
||||
Roamer.move(session, "warp_random")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local Ghosts = require("src.core.game3.ghosts")
|
||||
local fromMapId = Map._announced
|
||||
@@ -440,6 +454,16 @@ function Map.load(mod, game, mapId, opts)
|
||||
Objects.loadMap(game, mapId, def)
|
||||
Ghosts.adopt(mapId)
|
||||
end
|
||||
-- pokefirered/src/overworld.c:771 / :808 TryRegenerateRenewableHiddenItems
|
||||
local okRen, Renewable = pcall(require, "src.core.game3.renewable_hidden_items")
|
||||
if okRen and Renewable and Renewable.tryRegenerate then
|
||||
Renewable.tryRegenerate(session, def and (def.group or (def.pair and def.pair[1])), def and (def.num or (def.pair and def.pair[2])), mapId)
|
||||
end
|
||||
-- pokefirered/src/overworld.c:809 SetCurrentAndNextWeather
|
||||
if def and def.weather ~= nil then
|
||||
local Weather = require("src.core.game3.weather")
|
||||
Weather.apply(def.weather)
|
||||
end
|
||||
-- pokefirered/src/overworld.c:769
|
||||
-- pokefirered/src/overworld.c:806
|
||||
require("src.core.game3.audio").setSavedSong(nil)
|
||||
|
||||
@@ -468,10 +468,6 @@ function Player.tryMove(dir, game, run)
|
||||
end
|
||||
end
|
||||
end
|
||||
if (why == "bounds" or why == "solid" or why == "tile") and Collision.tryWarpAt
|
||||
and Collision.tryWarpAt(game, Player.cellX, Player.cellY, dir) then
|
||||
return "warp"
|
||||
end
|
||||
-- Outdoor map connection (Pallet north → Route 1, etc.).
|
||||
if why == "bounds" and Collision.tryConnection
|
||||
and Collision.tryConnection(game, Player.cellX, Player.cellY, dir, run) then
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
-- Renewable hidden items engine (Treasure Beach, Berry Forest, etc.)
|
||||
-- pokefirered/src/renewable_hidden_items.c
|
||||
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local F = Flags.IDS
|
||||
|
||||
local RenewableHiddenItems = {}
|
||||
|
||||
local VAR_RENEWABLE_ITEM_STEP_COUNTER = 0x4023 -- pokefirered/include/constants/vars.h:40
|
||||
local STEP_THRESHOLD = 1500
|
||||
|
||||
-- Map definitions and item tables matching pokefirered/src/renewable_hidden_items.c
|
||||
local RENEWABLE_ZONES = {
|
||||
{
|
||||
name = "ROUTE20",
|
||||
group = 3, map = 36,
|
||||
mapIds = { "ROUTE_20", "FR_ROUTE_20", "LG_ROUTE_20" },
|
||||
rare = {},
|
||||
uncommon = { F.FLAG_HIDDEN_ITEM_ROUTE20_STARDUST },
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "ROUTE21_NORTH",
|
||||
group = 3, map = 37,
|
||||
mapIds = { "ROUTE_21_NORTH", "FR_ROUTE_21_NORTH", "LG_ROUTE_21_NORTH", "ROUTE_21" },
|
||||
rare = {},
|
||||
uncommon = { F.FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL },
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL",
|
||||
group = 1, map = 31,
|
||||
mapIds = { "UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "FR_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" },
|
||||
rare = { F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER },
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL,
|
||||
},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "UNDERGROUND_PATH_EAST_WEST_TUNNEL",
|
||||
group = 1, map = 34,
|
||||
mapIds = { "UNDERGROUND_PATH_EAST_WEST_TUNNEL", "FR_UNDERGROUND_PATH_EAST_WEST_TUNNEL" },
|
||||
rare = { F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER },
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL,
|
||||
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL,
|
||||
},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "SEVEN_ISLAND_TANOBY_RUINS",
|
||||
group = 3, map = 45,
|
||||
mapIds = { "SEVII_SEVEN_ISLAND_TANOBY_RUINS", "SEVEN_ISLAND_TANOBY_RUINS", "FR_SEVEN_ISLAND_TANOBY_RUINS" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4,
|
||||
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE,
|
||||
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2,
|
||||
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3,
|
||||
},
|
||||
uncommon = {},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "MT_MOON_B1F",
|
||||
group = 1, map = 2,
|
||||
mapIds = { "MT_MOON_B1F", "FR_MT_MOON_B1F" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3,
|
||||
},
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2,
|
||||
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3,
|
||||
},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "THREE_ISLAND_BERRY_FOREST",
|
||||
group = 1, map = 109,
|
||||
mapIds = { "SEVII_THREE_ISLAND_BERRY_FOREST", "THREE_ISLAND_BERRY_FOREST", "FR_THREE_ISLAND_BERRY_FOREST" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY,
|
||||
},
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY,
|
||||
},
|
||||
common = {
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY,
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "ONE_ISLAND_TREASURE_BEACH",
|
||||
group = 3, map = 46,
|
||||
mapIds = { "SEVII_ONE_ISLAND_TREASURE_BEACH", "ONE_ISLAND_TREASURE_BEACH", "FR_ONE_ISLAND_TREASURE_BEACH" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL,
|
||||
},
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
|
||||
},
|
||||
common = {
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
|
||||
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "THREE_ISLAND_BOND_BRIDGE",
|
||||
group = 3, map = 48,
|
||||
mapIds = { "SEVII_THREE_ISLAND_BOND_BRIDGE", "THREE_ISLAND_BOND_BRIDGE", "FR_THREE_ISLAND_BOND_BRIDGE" },
|
||||
rare = {},
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL,
|
||||
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST,
|
||||
},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "FOUR_ISLAND",
|
||||
group = 3, map = 5,
|
||||
mapIds = { "SEVII_FOUR_ISLAND", "FOUR_ISLAND", "FR_FOUR_ISLAND" },
|
||||
rare = {},
|
||||
uncommon = { F.FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL },
|
||||
common = { F.FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL },
|
||||
},
|
||||
{
|
||||
name = "FIVE_ISLAND_MEMORIAL_PILLAR",
|
||||
group = 3, map = 51,
|
||||
mapIds = { "SEVII_FIVE_ISLAND_MEMORIAL_PILLAR", "FIVE_ISLAND_MEMORIAL_PILLAR", "FR_FIVE_ISLAND_MEMORIAL_PILLAR" },
|
||||
rare = { F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL },
|
||||
uncommon = {},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "FIVE_ISLAND_RESORT_GORGEOUS",
|
||||
group = 3, map = 52,
|
||||
mapIds = { "SEVII_FIVE_ISLAND_RESORT_GORGEOUS", "FIVE_ISLAND_RESORT_GORGEOUS", "FR_FIVE_ISLAND_RESORT_GORGEOUS" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL,
|
||||
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE,
|
||||
},
|
||||
uncommon = {
|
||||
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST,
|
||||
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2,
|
||||
},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "SIX_ISLAND_OUTCAST_ISLAND",
|
||||
group = 3, map = 53,
|
||||
mapIds = { "SEVII_SIX_ISLAND_OUTCAST_ISLAND", "SIX_ISLAND_OUTCAST_ISLAND", "FR_SIX_ISLAND_OUTCAST_ISLAND" },
|
||||
rare = {
|
||||
F.FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE,
|
||||
F.FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL,
|
||||
},
|
||||
uncommon = {},
|
||||
common = {},
|
||||
},
|
||||
{
|
||||
name = "SIX_ISLAND_GREEN_PATH",
|
||||
group = 3, map = 54,
|
||||
mapIds = { "SEVII_SIX_ISLAND_GREEN_PATH", "SIX_ISLAND_GREEN_PATH", "FR_SIX_ISLAND_GREEN_PATH" },
|
||||
rare = {},
|
||||
uncommon = {},
|
||||
common = { F.FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL },
|
||||
},
|
||||
{
|
||||
name = "SEVEN_ISLAND_TRAINER_TOWER",
|
||||
group = 3, map = 56,
|
||||
mapIds = { "SEVII_SEVEN_ISLAND_TRAINER_TOWER", "SEVEN_ISLAND_TRAINER_TOWER", "FR_SEVEN_ISLAND_TRAINER_TOWER" },
|
||||
rare = { F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL },
|
||||
uncommon = { F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL },
|
||||
common = {},
|
||||
},
|
||||
}
|
||||
|
||||
RenewableHiddenItems.ZONES = RENEWABLE_ZONES
|
||||
|
||||
local function resolveStore(ctx)
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
if Space and Space.store then return Space.store end
|
||||
local rt = package.loaded["src.core.game3.runtime"]
|
||||
local session = rt and rt.getSession and rt.getSession()
|
||||
if session and session.store then return session.store end
|
||||
if type(ctx) == "table" then
|
||||
if ctx.vars or ctx.flags then return ctx end
|
||||
if ctx.store then return ctx.store end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId)
|
||||
mapGroup = tonumber(mapGroup)
|
||||
mapNum = tonumber(mapNum)
|
||||
for _, zone in ipairs(RENEWABLE_ZONES) do
|
||||
if mapGroup and mapNum and zone.group == mapGroup and zone.map == mapNum then
|
||||
return true, zone
|
||||
end
|
||||
if mapId then
|
||||
for _, mid in ipairs(zone.mapIds) do
|
||||
if mid == mapId then return true, zone end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
--- pokefirered/src/renewable_hidden_items.c:557
|
||||
function RenewableHiddenItems.onStep(ctx, mapGroup, mapNum, mapId)
|
||||
-- Requirement 1: If player is inside one of the renewable item maps, do NOT increment
|
||||
if RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId) then
|
||||
return false
|
||||
end
|
||||
|
||||
local store = resolveStore(ctx)
|
||||
local var = tonumber(Flags.getVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER)) or 0
|
||||
if var < STEP_THRESHOLD then
|
||||
Flags.setVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER, var + 1)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- pokefirered/src/renewable_hidden_items.c:536
|
||||
local function setAllRenewableFlags(ctx)
|
||||
local store = resolveStore(ctx)
|
||||
for _, zone in ipairs(RENEWABLE_ZONES) do
|
||||
for _, flagId in ipairs(zone.rare) do
|
||||
if flagId then Flags.setFlag(store, ctx, flagId, true) end
|
||||
end
|
||||
for _, flagId in ipairs(zone.uncommon) do
|
||||
if flagId then Flags.setFlag(store, ctx, flagId, true) end
|
||||
end
|
||||
for _, flagId in ipairs(zone.common) do
|
||||
if flagId then Flags.setFlag(store, ctx, flagId, true) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- pokefirered/src/renewable_hidden_items.c:587
|
||||
local function sampleRenewableFlags(ctx, rngFn)
|
||||
local store = resolveStore(ctx)
|
||||
local rand = rngFn or function() return math.random(0, 99) end
|
||||
|
||||
for _, zone in ipairs(RENEWABLE_ZONES) do
|
||||
local rval = rand() % 100
|
||||
local flags
|
||||
if rval >= 90 then
|
||||
flags = zone.rare
|
||||
elseif rval >= 60 then
|
||||
flags = zone.uncommon
|
||||
else
|
||||
flags = zone.common
|
||||
end
|
||||
|
||||
for _, flagId in ipairs(flags) do
|
||||
if flagId then
|
||||
-- FlagClear makes the item exist/spawn on the map
|
||||
Flags.setFlag(store, ctx, flagId, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- pokefirered/src/renewable_hidden_items.c:566
|
||||
function RenewableHiddenItems.tryRegenerate(ctx, mapGroup, mapNum, mapId, rngFn)
|
||||
local isRenewable, zone = RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId)
|
||||
if not isRenewable then return false end
|
||||
|
||||
local store = resolveStore(ctx)
|
||||
local var = tonumber(Flags.getVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER)) or 0
|
||||
if var >= STEP_THRESHOLD then
|
||||
Flags.setVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER, 0)
|
||||
setAllRenewableFlags(ctx)
|
||||
sampleRenewableFlags(ctx, rngFn)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return RenewableHiddenItems
|
||||
@@ -0,0 +1,321 @@
|
||||
-- Roaming Legendary Beast system matching pokefirered (src/roamer.c)
|
||||
-- Raikou, Entei, and Suicune in Kanto.
|
||||
|
||||
local Rng = require("src.core.game3.rng")
|
||||
local Pokemon = require("src.core.game3.pokemon")
|
||||
|
||||
local Roamer = {}
|
||||
|
||||
Roamer.SPECIES_RAIKOU = 243
|
||||
Roamer.SPECIES_ENTEI = 244
|
||||
Roamer.SPECIES_SUICUNE = 245
|
||||
Roamer.ROAMER_LEVEL = 50
|
||||
|
||||
-- include/constants/vars.h:47
|
||||
local VAR_REPEL_STEP_COUNT = 0x4020
|
||||
local VAR_STARTER_MON = 0x4031
|
||||
|
||||
-- pokefirered/src/roamer.c:19 sRoamerLocations (Kanto routes)
|
||||
Roamer.LOCATIONS = {
|
||||
"FR_ROUTE_1",
|
||||
"FR_ROUTE_2",
|
||||
"FR_ROUTE_3",
|
||||
"FR_ROUTE_4",
|
||||
"FR_ROUTE_5",
|
||||
"FR_ROUTE_6",
|
||||
"FR_ROUTE_7",
|
||||
"FR_ROUTE_8",
|
||||
"FR_ROUTE_9",
|
||||
"FR_ROUTE_10",
|
||||
"FR_ROUTE_11",
|
||||
"FR_ROUTE_12",
|
||||
"FR_ROUTE_13",
|
||||
"FR_ROUTE_14",
|
||||
"FR_ROUTE_15",
|
||||
"FR_ROUTE_16",
|
||||
"FR_ROUTE_17",
|
||||
"FR_ROUTE_18",
|
||||
"FR_ROUTE_19",
|
||||
"FR_ROUTE_20",
|
||||
"FR_ROUTE_21_NORTH",
|
||||
"FR_ROUTE_21_SOUTH",
|
||||
"FR_ROUTE_24",
|
||||
"FR_ROUTE_25",
|
||||
}
|
||||
|
||||
-- Adjacency table matching pokefirered/src/roamer.c:30 sRoamerLocationHistory
|
||||
Roamer.ADJACENCY = {
|
||||
["FR_ROUTE_1"] = { "FR_ROUTE_2", "FR_ROUTE_21_NORTH", "FR_ROUTE_21_SOUTH" },
|
||||
["FR_ROUTE_2"] = { "FR_ROUTE_1", "FR_ROUTE_3", "FR_ROUTE_22" },
|
||||
["FR_ROUTE_3"] = { "FR_ROUTE_2", "FR_ROUTE_4" },
|
||||
["FR_ROUTE_4"] = { "FR_ROUTE_3", "FR_ROUTE_9", "FR_ROUTE_24" },
|
||||
["FR_ROUTE_5"] = { "FR_ROUTE_6", "FR_ROUTE_7", "FR_ROUTE_8", "FR_ROUTE_24" },
|
||||
["FR_ROUTE_6"] = { "FR_ROUTE_5", "FR_ROUTE_7", "FR_ROUTE_8", "FR_ROUTE_11" },
|
||||
["FR_ROUTE_7"] = { "FR_ROUTE_5", "FR_ROUTE_6", "FR_ROUTE_8", "FR_ROUTE_16" },
|
||||
["FR_ROUTE_8"] = { "FR_ROUTE_5", "FR_ROUTE_6", "FR_ROUTE_7", "FR_ROUTE_10", "FR_ROUTE_12" },
|
||||
["FR_ROUTE_9"] = { "FR_ROUTE_4", "FR_ROUTE_10", "FR_ROUTE_24" },
|
||||
["FR_ROUTE_10"] = { "FR_ROUTE_8", "FR_ROUTE_9", "FR_ROUTE_12" },
|
||||
["FR_ROUTE_11"] = { "FR_ROUTE_6", "FR_ROUTE_12" },
|
||||
["FR_ROUTE_12"] = { "FR_ROUTE_8", "FR_ROUTE_10", "FR_ROUTE_11", "FR_ROUTE_13" },
|
||||
["FR_ROUTE_13"] = { "FR_ROUTE_12", "FR_ROUTE_14" },
|
||||
["FR_ROUTE_14"] = { "FR_ROUTE_13", "FR_ROUTE_15" },
|
||||
["FR_ROUTE_15"] = { "FR_ROUTE_14", "FR_ROUTE_18" },
|
||||
["FR_ROUTE_16"] = { "FR_ROUTE_7", "FR_ROUTE_17" },
|
||||
["FR_ROUTE_17"] = { "FR_ROUTE_16", "FR_ROUTE_18" },
|
||||
["FR_ROUTE_18"] = { "FR_ROUTE_15", "FR_ROUTE_17", "FR_ROUTE_19" },
|
||||
["FR_ROUTE_19"] = { "FR_ROUTE_18", "FR_ROUTE_20" },
|
||||
["FR_ROUTE_20"] = { "FR_ROUTE_19", "FR_ROUTE_21_SOUTH" },
|
||||
["FR_ROUTE_21_NORTH"] = { "FR_ROUTE_1", "FR_ROUTE_21_SOUTH" },
|
||||
["FR_ROUTE_21_SOUTH"] = { "FR_ROUTE_20", "FR_ROUTE_21_NORTH" },
|
||||
["FR_ROUTE_24"] = { "FR_ROUTE_4", "FR_ROUTE_5", "FR_ROUTE_9", "FR_ROUTE_25" },
|
||||
["FR_ROUTE_25"] = { "FR_ROUTE_24" },
|
||||
}
|
||||
|
||||
local function normalize_map_id(mapId)
|
||||
if type(mapId) ~= "string" then return nil end
|
||||
local s = tostring(mapId):upper()
|
||||
|
||||
-- Check MapCatalog if available
|
||||
local ok, MapCatalog = pcall(require, "src.import.gba.map_catalog")
|
||||
if ok and MapCatalog and MapCatalog.resolve then
|
||||
local res = MapCatalog.resolve(s)
|
||||
if res then s = tostring(res):upper() end
|
||||
end
|
||||
|
||||
if s:find("ROUTE_?21_?NORTH") or s:find("ROUTE_?21_?SOUTH") then
|
||||
if s:find("SOUTH") then return "FR_ROUTE_21_SOUTH" end
|
||||
return "FR_ROUTE_21_NORTH"
|
||||
end
|
||||
if s == "FR_ROUTE21" or s == "ROUTE_21" or s == "FR_ROUTE_21" or s == "ROUTE21" then
|
||||
return "FR_ROUTE_21_NORTH"
|
||||
end
|
||||
|
||||
local num = s:match("ROUTE_?(%d+)")
|
||||
if num then
|
||||
local target = "FR_ROUTE_" .. num
|
||||
for _, loc in ipairs(Roamer.LOCATIONS) do
|
||||
if loc == target then return target end
|
||||
end
|
||||
end
|
||||
|
||||
for _, loc in ipairs(Roamer.LOCATIONS) do
|
||||
if s == loc or s == loc:gsub("^FR_", "") or s == loc:gsub("_", "") or s == ("FR_" .. s:gsub("_", "")) then
|
||||
return loc
|
||||
end
|
||||
end
|
||||
return s
|
||||
end
|
||||
Roamer.normalizeMapId = normalize_map_id
|
||||
|
||||
--- Determine species based on starter:
|
||||
--- Bulbasaur (0) -> Entei (244)
|
||||
--- Squirtle (1) -> Raikou (243)
|
||||
--- Charmander (2) -> Suicune (245)
|
||||
function Roamer.speciesForStarter(starter)
|
||||
starter = tonumber(starter) or 0
|
||||
if starter == 1 then
|
||||
return Roamer.SPECIES_RAIKOU
|
||||
elseif starter == 2 then
|
||||
return Roamer.SPECIES_SUICUNE
|
||||
else
|
||||
return Roamer.SPECIES_ENTEI
|
||||
end
|
||||
end
|
||||
|
||||
local function random_32bit()
|
||||
local hi = Rng.Random() % 65536
|
||||
local lo = Rng.Random() % 65536
|
||||
return hi * 65536 + lo
|
||||
end
|
||||
|
||||
--- Generates standard 0-31 IV block (fixing the vanilla Gen 3 hardware truncation bug)
|
||||
function Roamer.generateMon(species, level)
|
||||
level = level or Roamer.ROAMER_LEVEL
|
||||
local pid = random_32bit()
|
||||
local ivs = {
|
||||
hp = Rng.Random() % 32,
|
||||
attack = Rng.Random() % 32,
|
||||
defense = Rng.Random() % 32,
|
||||
speed = Rng.Random() % 32,
|
||||
spAtk = Rng.Random() % 32,
|
||||
spDef = Rng.Random() % 32,
|
||||
}
|
||||
|
||||
local moves, pp = nil, nil
|
||||
if Pokemon and Pokemon.movesAtLevel then
|
||||
moves, pp = Pokemon.movesAtLevel(species, level)
|
||||
end
|
||||
|
||||
local hpBase = (species == Roamer.SPECIES_RAIKOU and 90)
|
||||
or (species == Roamer.SPECIES_ENTEI and 115)
|
||||
or (species == Roamer.SPECIES_SUICUNE and 100) or 100
|
||||
local maxHp = math.floor(((2 * hpBase + ivs.hp) * level) / 100) + level + 10
|
||||
|
||||
return {
|
||||
species = species,
|
||||
speciesId = species,
|
||||
level = level,
|
||||
hp = maxHp,
|
||||
maxHp = maxHp,
|
||||
status = 0,
|
||||
statusNum = 0,
|
||||
pid = pid,
|
||||
ivs = ivs,
|
||||
moves = moves or {},
|
||||
pp = pp or {},
|
||||
}
|
||||
end
|
||||
|
||||
--- Initialize roamer on Celio Ruby/Sapphire completion (special InitRoamer)
|
||||
function Roamer.init(session, starterChoice)
|
||||
if not session then return false end
|
||||
local species = Roamer.speciesForStarter(starterChoice)
|
||||
local mon = Roamer.generateMon(species, Roamer.ROAMER_LEVEL)
|
||||
|
||||
-- Initial location: pick random Kanto route
|
||||
local locIndex = (Rng.Random() % #Roamer.LOCATIONS) + 1
|
||||
local initialMap = Roamer.LOCATIONS[locIndex]
|
||||
|
||||
session.roamer = {
|
||||
active = true,
|
||||
species = species,
|
||||
level = Roamer.ROAMER_LEVEL,
|
||||
hp = mon.hp or mon.maxHp,
|
||||
maxHp = mon.maxHp or mon.hp,
|
||||
status = mon.status or 0,
|
||||
statusNum = mon.statusNum or 0,
|
||||
pid = mon.pid,
|
||||
ivs = mon.ivs,
|
||||
moves = mon.moves,
|
||||
pp = mon.pp,
|
||||
map = initialMap,
|
||||
}
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Jump roamer to a completely random Kanto route (Fly, Teleport, Escape Rope, Whiteout)
|
||||
function Roamer.jump(session)
|
||||
if not (session and session.roamer and session.roamer.active) then return end
|
||||
local cur = session.roamer.map
|
||||
local locs = Roamer.LOCATIONS
|
||||
local pick
|
||||
for _ = 1, 10 do
|
||||
pick = locs[(Rng.Random() % #locs) + 1]
|
||||
if pick ~= cur then break end
|
||||
end
|
||||
session.roamer.map = pick or locs[1]
|
||||
end
|
||||
|
||||
--- Step roamer across connected routes (on map transition)
|
||||
function Roamer.move(session, reason)
|
||||
if not (session and session.roamer and session.roamer.active) then return end
|
||||
if reason == "warp_random" then
|
||||
Roamer.jump(session)
|
||||
return
|
||||
end
|
||||
|
||||
local cur = session.roamer.map
|
||||
local adj = Roamer.ADJACENCY[cur]
|
||||
if not adj or #adj == 0 then
|
||||
Roamer.jump(session)
|
||||
return
|
||||
end
|
||||
|
||||
-- ~16/256 chance to stay on same route, otherwise pick adjacent route
|
||||
if (Rng.Random() % 16) == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local pick = adj[(Rng.Random() % #adj) + 1]
|
||||
session.roamer.map = pick or cur
|
||||
end
|
||||
|
||||
--- Check party slot 1 level for Repel check (even if fainted!)
|
||||
local function getLeadMonLevel(session)
|
||||
local party = session and session.party
|
||||
if type(party) ~= "table" then return 0 end
|
||||
for i = 1, #party do
|
||||
local mon = party[i]
|
||||
-- Eggs are ignored; fainted lead Pokémon in slot 1 IS evaluated by Repel!
|
||||
if type(mon) == "table" and not mon.isEgg and not mon.egg then
|
||||
return tonumber(mon.level) or tonumber(mon.lvl) or 1
|
||||
end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
--- Intercept wild encounter if roamer is active on current map
|
||||
function Roamer.tryEncounter(session, mapId, terrain)
|
||||
if not (session and session.roamer and session.roamer.active) then return nil end
|
||||
local roamer = session.roamer
|
||||
if roamer.hp <= 0 then return nil end
|
||||
|
||||
local normMap = normalize_map_id(mapId)
|
||||
local roamerMap = normalize_map_id(roamer.map)
|
||||
if normMap ~= roamerMap then return nil end
|
||||
|
||||
-- Only wild land/grass or surf water triggers
|
||||
if terrain ~= "land" and terrain ~= "grass" and terrain ~= "water" then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Repel check (respects fainted slot-1 Pokémon level)
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
local store = (Space and Space.store) or session.store or session
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local repelSteps = tonumber(Flags.getVar(store, nil, VAR_REPEL_STEP_COUNT)) or 0
|
||||
if repelSteps > 0 then
|
||||
local leadLv = getLeadMonLevel(session)
|
||||
if leadLv > roamer.level then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Construct wild foe encounter descriptor
|
||||
local foe = {
|
||||
species = roamer.species,
|
||||
speciesId = roamer.species,
|
||||
level = roamer.level,
|
||||
hp = roamer.hp,
|
||||
maxHp = roamer.maxHp,
|
||||
status = roamer.status,
|
||||
statusNum = roamer.statusNum,
|
||||
pid = roamer.pid,
|
||||
ivs = roamer.ivs,
|
||||
moves = roamer.moves,
|
||||
pp = roamer.pp,
|
||||
roamer = true,
|
||||
}
|
||||
|
||||
return {
|
||||
species = roamer.species,
|
||||
level = roamer.level,
|
||||
roamer = true,
|
||||
foe = foe,
|
||||
}
|
||||
end
|
||||
|
||||
--- Update roamer status and HP on battle completion
|
||||
function Roamer.onBattleEnd(session, foeState, battleResult, endReason)
|
||||
if not (session and session.roamer and session.roamer.active) then return end
|
||||
local roamer = session.roamer
|
||||
|
||||
if foeState then
|
||||
roamer.hp = math.max(0, tonumber(foeState.hp) or roamer.hp)
|
||||
roamer.status = foeState.status or roamer.status
|
||||
roamer.statusNum = foeState.statusNum or roamer.statusNum
|
||||
end
|
||||
|
||||
-- Caught or defeated -> permanently deactivate
|
||||
if battleResult == "caught" or (foeState and foeState.hp and foeState.hp <= 0) then
|
||||
roamer.active = false
|
||||
return
|
||||
end
|
||||
|
||||
-- If battle ended by fleeing, player running, or Roar/Whirlwind:
|
||||
-- Prevent Gen 3 Roar despawn bug: keep roamer alive and migrate to new location
|
||||
Roamer.jump(session)
|
||||
end
|
||||
|
||||
return Roamer
|
||||
@@ -251,6 +251,10 @@ function Runtime.update(dt)
|
||||
|
||||
local okF, Fade = pcall(require, "src.ui.game3.fade")
|
||||
if okF and Fade.tick then Fade.tick(dt) end
|
||||
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
|
||||
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
|
||||
SeagallopUi.update(dt)
|
||||
end
|
||||
local okTr, BattleTransition = pcall(require, "src.core.game3.battle_transition")
|
||||
if okTr and BattleTransition.isActive and BattleTransition.isActive() then
|
||||
BattleTransition.tick(dt)
|
||||
@@ -264,8 +268,15 @@ function Runtime.update(dt)
|
||||
Audio.tickCry(dt)
|
||||
end
|
||||
|
||||
local okW, FieldWeather = pcall(require, "src.core.game3.field_weather")
|
||||
if okW and FieldWeather and FieldWeather.update then
|
||||
FieldWeather.update(dt)
|
||||
end
|
||||
|
||||
local Battle = require("src.core.game3.battle")
|
||||
if Battle.isActive() then
|
||||
local Weather = require("src.core.game3.weather")
|
||||
Weather.suspend()
|
||||
Battle.update(dt, game)
|
||||
-- Keep script VM + message typewriter alive while battle runs.
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
@@ -278,6 +289,9 @@ function Runtime.update(dt)
|
||||
if Message and Message.tick then Message.tick() end
|
||||
Hud.update(game, dt, inputTop)
|
||||
return
|
||||
else
|
||||
local Weather = require("src.core.game3.weather")
|
||||
Weather.resume()
|
||||
end
|
||||
|
||||
if not inMenu then
|
||||
|
||||
@@ -217,6 +217,7 @@ function Schema.newGame(opts)
|
||||
secretId = nil,
|
||||
rng = nil,
|
||||
vsSeeker = { steps = 0, charging = 0, rematches = {} },
|
||||
roamer = nil,
|
||||
}
|
||||
-- pret new_game.c: SeedWildEncounterRng(Random()) after title SeedRngAndSetTrainerId.
|
||||
local Rng = require("src.core.game3.rng")
|
||||
@@ -305,6 +306,7 @@ function Schema.toSaveTable(session)
|
||||
secretId = session.secretId,
|
||||
rng = session.rng,
|
||||
vsSeeker = session.vsSeeker,
|
||||
roamer = session.roamer,
|
||||
-- GAME_STAT_* counters: slot-machine jackpots, hatched eggs, link W/L/D,
|
||||
-- link trades, and the sticker-man brags that read them.
|
||||
gameStats = session.gameStats or {},
|
||||
@@ -414,6 +416,7 @@ function Schema.fromSaveTable(save)
|
||||
playerId = save.trainerId,
|
||||
rng = save.rng,
|
||||
vsSeeker = type(save.vsSeeker) == "table" and save.vsSeeker or { steps = 0, charging = 0, rematches = {} },
|
||||
roamer = type(save.roamer) == "table" and save.roamer or nil,
|
||||
-- Additive: a save written before this key exists loads as an empty table.
|
||||
gameStats = type(save.gameStats) == "table" and save.gameStats or {},
|
||||
-- Additive: older saves load these as empty tables.
|
||||
@@ -439,6 +442,7 @@ function Schema.fromSaveTable(save)
|
||||
Schema.repairOwnMons(session)
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
Flags.repairSaveState(session)
|
||||
Schema.repairRoamer(session)
|
||||
if type(save.options) == "table" then
|
||||
Options.bind(session, save.options)
|
||||
else
|
||||
@@ -447,6 +451,22 @@ function Schema.fromSaveTable(save)
|
||||
return session
|
||||
end
|
||||
|
||||
function Schema.repairRoamer(session)
|
||||
if not session or session.roamer then return end
|
||||
local FLAG_SYS_CAN_LINK_WITH_RS = 0x844
|
||||
local VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F = 0x4076
|
||||
local VAR_STARTER_MON = 0x4031
|
||||
local flags = session.flags or {}
|
||||
local hasLink = (flags[FLAG_SYS_CAN_LINK_WITH_RS] == true) or (flags["FLAG_SYS_CAN_LINK_WITH_RS"] == true)
|
||||
local vars = session.vars or {}
|
||||
local sceneVal = tonumber(vars[VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F] or vars["VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F"]) or 0
|
||||
if hasLink or sceneVal >= 6 then
|
||||
local Roamer = require("src.core.game3.roamer")
|
||||
local starter = tonumber(vars[VAR_STARTER_MON] or vars["VAR_STARTER_MON"]) or 0
|
||||
Roamer.init(session, starter)
|
||||
end
|
||||
end
|
||||
|
||||
function Schema.ensureMonBall(mon)
|
||||
if type(mon) ~= "table" then return end
|
||||
-- pokefirered/src/pokemon.c:1820
|
||||
|
||||
@@ -362,7 +362,16 @@ Events.HANDLERS = {
|
||||
return false
|
||||
end,
|
||||
-- src/roamer.c:120
|
||||
[Std.SPECIAL.InitRoamer] = noop,
|
||||
[Std.SPECIAL.InitRoamer] = function(ctx)
|
||||
local session = sessionOf(ctx)
|
||||
local VAR_STARTER_MON = 0x4031 -- pokefirered/include/constants/vars.h:98
|
||||
local starter = varGet(ctx, VAR_STARTER_MON)
|
||||
local okR, Roamer = pcall(require, "src.core.game3.roamer")
|
||||
if okR and Roamer and Roamer.init then
|
||||
Roamer.init(session, starter)
|
||||
end
|
||||
return false
|
||||
end,
|
||||
-- src/field_specials.c:679-690
|
||||
[Std.SPECIAL.SampleResortGorgeousMonAndReward] = function(ctx, adapters)
|
||||
local session = sessionOf(ctx)
|
||||
|
||||
@@ -146,9 +146,26 @@ Seagallop.MENU_LIST_ID = 0xF001
|
||||
|
||||
-- pokefirered/src/seagallop.c:174
|
||||
function Seagallop.ferryTask(ctx, adapters, destId)
|
||||
local originId = varGet(ctx, VAR_ORIGIN)
|
||||
local warp = WARPS[destId]
|
||||
local done = false
|
||||
local okS, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
|
||||
local hasGraphics = _G.love and type(_G.love.graphics) == "table" and _G.love.window ~= nil
|
||||
if okS and SeagallopUi and SeagallopUi.start and hasGraphics then
|
||||
SeagallopUi.start(originId, destId, function()
|
||||
if warp and adapters and adapters.warp then
|
||||
adapters.warp(warp[1], warp[2], -1, warp[3], warp[4], function() end, "seagallop")
|
||||
elseif adapters and adapters.log then
|
||||
adapters.log(string.format("[game3] seagallop has no warp for dest %s", tostring(destId)))
|
||||
end
|
||||
end, function()
|
||||
done = true
|
||||
end)
|
||||
return function() return done end
|
||||
end
|
||||
|
||||
local frames = 0
|
||||
local phase = "cross"
|
||||
local warp = WARPS[destId]
|
||||
local waited, arrived = 0, false
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
if not okA then Audio = nil end
|
||||
|
||||
@@ -157,6 +157,12 @@ function StepEvents.onStepTaken(session, game)
|
||||
-- pokefirered/src/field_specials.c:2433 IncrementBirthIslandRockStepCount
|
||||
require("src.core.game3.deoxys").incrementStepCount(session)
|
||||
|
||||
-- pokefirered/src/field_control_avatar.c:219 IncrementRenewableHiddenItemStepCounter
|
||||
local okRen, Renewable = pcall(require, "src.core.game3.renewable_hidden_items")
|
||||
if okRen and Renewable and Renewable.onStep then
|
||||
Renewable.onStep(session, session.mapGroup, session.mapNum, session.map)
|
||||
end
|
||||
|
||||
-- pokefirered/src/field_control_avatar.c:658
|
||||
local forced = forced_step()
|
||||
local poisonFainted = false
|
||||
@@ -207,7 +213,7 @@ function StepEvents.onStepTaken(session, game)
|
||||
if anyPoisonDamage then
|
||||
-- Trigger 4-frame reddish screen flash and poison SE
|
||||
StepEvents._poisonFlashTimer = 4 / 60
|
||||
se(35) -- SE_FIELD_POISON
|
||||
se(72) -- SE_FIELD_POISON (72)
|
||||
|
||||
-- pokefirered/src/field_control_avatar.c:727 FLDPSN_FNT
|
||||
poisonFainted = #faintedMons > 0
|
||||
|
||||
+49
-11
@@ -1,25 +1,42 @@
|
||||
-- Field weather session state (H5). Ops setweather / doweather / resetweather.
|
||||
-- pokefirered/include/constants/weather.h
|
||||
-- pokefirered/src/field_weather.c
|
||||
|
||||
local Weather = {}
|
||||
|
||||
Weather.NONE = 0
|
||||
Weather.RAIN = 1
|
||||
Weather.ASH = 2
|
||||
Weather.FOG = 3
|
||||
Weather.SANDSTORM = 4
|
||||
Weather.SUNNY = 5
|
||||
Weather.SUNNY_CLOUDS = 1
|
||||
Weather.SUNNY = 2
|
||||
Weather.RAIN = 3
|
||||
Weather.SNOW = 4
|
||||
Weather.RAIN_THUNDERSTORM = 5
|
||||
Weather.FOG_HORIZONTAL = 6
|
||||
Weather.VOLCANIC_ASH = 7
|
||||
Weather.SANDSTORM = 8
|
||||
Weather.FOG_DIAGONAL = 9
|
||||
Weather.UNDERWATER = 10
|
||||
Weather.SHADE = 11
|
||||
Weather.DROUGHT = 12
|
||||
Weather.DOWNPOUR = 13
|
||||
Weather.UNDERWATER_BUBBLES = 14
|
||||
Weather.ABNORMAL = 15
|
||||
|
||||
Weather.current = 0
|
||||
-- Aliases
|
||||
Weather.FOG = Weather.FOG_HORIZONTAL
|
||||
Weather.ASH = Weather.VOLCANIC_ASH
|
||||
|
||||
Weather.current = Weather.NONE
|
||||
Weather._pending = nil
|
||||
Weather._active = false
|
||||
Weather._suspended = false
|
||||
|
||||
function Weather.set(id)
|
||||
Weather._pending = tonumber(id) or 0
|
||||
Weather._pending = tonumber(id) or Weather.NONE
|
||||
end
|
||||
|
||||
function Weather.doWeather()
|
||||
Weather.current = Weather._pending or Weather.current
|
||||
Weather._active = Weather.current ~= Weather.NONE
|
||||
Weather._active = Weather.current ~= Weather.NONE and Weather.current ~= Weather.SUNNY
|
||||
Weather.apply(Weather.current)
|
||||
end
|
||||
|
||||
@@ -31,9 +48,14 @@ function Weather.reset()
|
||||
end
|
||||
|
||||
function Weather.apply(id)
|
||||
Weather.current = tonumber(id) or 0
|
||||
Weather._active = Weather.current ~= Weather.NONE
|
||||
-- Overlay hook: host World may expose weather palette; optional.
|
||||
Weather.current = tonumber(id) or Weather.NONE
|
||||
Weather._active = Weather.current ~= Weather.NONE and Weather.current ~= Weather.SUNNY
|
||||
|
||||
local okFw, FieldWeather = pcall(require, "src.core.game3.field_weather")
|
||||
if okFw and FieldWeather and FieldWeather.setWeather then
|
||||
FieldWeather.setWeather(Weather.current)
|
||||
end
|
||||
|
||||
local Runtime = package.loaded["src.core.game3.runtime"]
|
||||
local game = Runtime and Runtime._game
|
||||
local world = game and (game.overworld or game.world)
|
||||
@@ -46,4 +68,20 @@ function Weather.get()
|
||||
return Weather.current
|
||||
end
|
||||
|
||||
function Weather.isActive()
|
||||
return Weather._active
|
||||
end
|
||||
|
||||
function Weather.suspend()
|
||||
Weather._suspended = true
|
||||
end
|
||||
|
||||
function Weather.resume()
|
||||
Weather._suspended = false
|
||||
end
|
||||
|
||||
function Weather.isSuspended()
|
||||
return Weather._suspended
|
||||
end
|
||||
|
||||
return Weather
|
||||
|
||||
@@ -278,6 +278,7 @@ function RomExtractorGen3:runPokemonExtract(sha1)
|
||||
local SeagallopExtract = require("src.import.gba.seagallop_extract")
|
||||
SeagallopExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
|
||||
require("src.import.gba.cave_transition_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
|
||||
require("src.import.gba.weather_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
|
||||
require("src.import.gba.ingame_trades_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
|
||||
require("src.import.gba.union_room_classes_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
|
||||
local MapPreviewExtract = require("src.import.gba.map_preview_extract")
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Weather graphics and palette extraction (Fog, Rain, default weather palette)
|
||||
-- pokefirered/src/field_weather.c
|
||||
|
||||
local Versions = require("src.import.gba.versions")
|
||||
|
||||
local WeatherExtract = {}
|
||||
|
||||
WeatherExtract.CACHE_SUB = "weather"
|
||||
WeatherExtract.FORMAT_VERSION = 1
|
||||
|
||||
WeatherExtract.BLOBS = {
|
||||
{ key = "default_pal", rel = "default.gbapal", offset = 0x3C2CE0, size = 32 },
|
||||
{ key = "fog_h_gfx", rel = "fog_horizontal.4bpp", offset = 0x3C3540, size = 2048 },
|
||||
{ key = "rain_gfx", rel = "rain.4bpp", offset = 0x3C55E0, size = 1536 },
|
||||
}
|
||||
|
||||
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 bytes_to_array(v)
|
||||
if type(v) == "table" then return v end
|
||||
local out = {}
|
||||
if type(v) == "string" then
|
||||
for i = 1, #v do out[i] = v:byte(i) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function load_pal(bytes, count)
|
||||
bytes = bytes_to_array(bytes)
|
||||
local pal = {}
|
||||
for i = 0, count - 1 do
|
||||
pal[i] = (bytes[i * 2 + 1] or 0) + (bytes[i * 2 + 2] or 0) * 256
|
||||
end
|
||||
return pal
|
||||
end
|
||||
|
||||
local function decode_tile_4bpp(gfx, base, out)
|
||||
for row = 0, 7 do
|
||||
for col = 0, 7 do
|
||||
local b = gfx[base + row * 4 + math.floor(col / 2) + 1] or 0
|
||||
local idx
|
||||
if col % 2 == 0 then idx = b % 16 else idx = math.floor(b / 16) end
|
||||
out[row * 8 + col + 1] = idx
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function WeatherExtract.bakeSheet(gfxBytes, palBytes, cols)
|
||||
local gfx = bytes_to_array(gfxBytes)
|
||||
local pal = load_pal(palBytes, 16)
|
||||
local tileCount = math.floor(#gfx / 32)
|
||||
cols = math.max(1, cols or 8)
|
||||
local rows = math.ceil(tileCount / cols)
|
||||
local W, H = cols * 8, rows * 8
|
||||
local chunks = {}
|
||||
for i = 1, W * H do chunks[i] = "\0\0\0\0" end
|
||||
local tmp = {}
|
||||
for t = 0, tileCount - 1 do
|
||||
decode_tile_4bpp(gfx, t * 32, tmp)
|
||||
local ox, oy = (t % cols) * 8, math.floor(t / cols) * 8
|
||||
for row = 0, 7 do
|
||||
for col = 0, 7 do
|
||||
local idx = tmp[row * 8 + col + 1] or 0
|
||||
if idx ~= 0 then
|
||||
local r, g, b = bgr555_to_rgb8(pal[idx] or 0)
|
||||
chunks[(oy + row) * W + ox + col + 1] = string.char(r, g, b, 255)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return table.concat(chunks), W, H, tileCount
|
||||
end
|
||||
|
||||
function WeatherExtract.readBlobs(rom, cfg)
|
||||
cfg = cfg or {}
|
||||
local out = {}
|
||||
for _, blob in ipairs(WeatherExtract.BLOBS) do
|
||||
local off = cfg[blob.key] or Versions.address(blob.offset)
|
||||
local bytes = rom:readBytes(off, blob.size)
|
||||
local chars = {}
|
||||
for i = 1, blob.size do chars[i] = string.char(bytes[i] or 0) end
|
||||
out[blob.key] = table.concat(chars)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function write_cache(cache, rel, data)
|
||||
if type(cache) ~= "table" or type(cache.write) ~= "function" then return end
|
||||
local ok, res, err = pcall(function() return cache:write(rel, data) end)
|
||||
if not ok or res == nil then res, err = cache.write(rel, data) end
|
||||
return res
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
function WeatherExtract.ready(cache, cacheRoot)
|
||||
local root = (cacheRoot or default_cache_root()) .. "/" .. WeatherExtract.CACHE_SUB
|
||||
if not (cache and cache.read) then return false end
|
||||
local fog = cache:read(root .. "/fog_horizontal.rgba")
|
||||
return type(fog) == "string" and #fog == 64 * 64 * 4
|
||||
end
|
||||
|
||||
function WeatherExtract.run(rom, cache, opts)
|
||||
opts = opts or {}
|
||||
local root = (opts.cacheRoot or default_cache_root()) .. "/" .. WeatherExtract.CACHE_SUB
|
||||
local cfg = opts.offsets or {}
|
||||
local blobs = WeatherExtract.readBlobs(rom, cfg)
|
||||
|
||||
for _, blob in ipairs(WeatherExtract.BLOBS) do
|
||||
write_cache(cache, root .. "/" .. blob.rel, blobs[blob.key])
|
||||
end
|
||||
|
||||
local fogRgba, fogW, fogH = WeatherExtract.bakeSheet(blobs.fog_h_gfx, blobs.default_pal, 8)
|
||||
write_cache(cache, root .. "/fog_horizontal.rgba", fogRgba)
|
||||
|
||||
local rainRgba, rainW, rainH = WeatherExtract.bakeSheet(blobs.rain_gfx, blobs.default_pal, 2)
|
||||
write_cache(cache, root .. "/rain.rgba", rainRgba)
|
||||
|
||||
write_cache(cache, root .. "/manifest.lua", string.format([[
|
||||
return {
|
||||
format = %d,
|
||||
fog_h = { file = "fog_horizontal.rgba", w = %d, h = %d },
|
||||
rain = { file = "rain.rgba", w = %d, h = %d },
|
||||
}
|
||||
]], WeatherExtract.FORMAT_VERSION, fogW, fogH, rainW, rainH))
|
||||
|
||||
return { format = WeatherExtract.FORMAT_VERSION }
|
||||
end
|
||||
|
||||
return WeatherExtract
|
||||
@@ -0,0 +1,303 @@
|
||||
-- Seagallop high-speed ferry cutscene (FRLG 1:1)
|
||||
-- pokefirered/src/seagallop.c
|
||||
|
||||
local Fade = require("src.ui.game3.fade")
|
||||
|
||||
local Seagallop = {}
|
||||
|
||||
local W, H = 240, 160
|
||||
local SE_SHIP = 19 -- pokefirered/include/constants/songs.h:23
|
||||
local SE_EXIT = 9 -- pokefirered/include/constants/songs.h:13
|
||||
local CROSSING_FRAMES = 140
|
||||
|
||||
local DIRN_WESTBOUND = 0
|
||||
local DIRN_EASTBOUND = 1
|
||||
|
||||
-- pokefirered/src/seagallop.c:88
|
||||
local TRAVEL_DIRECTIONS = {
|
||||
[0] = 0x6fe, -- VERMILION_CITY
|
||||
[1] = 0x6fc, -- ONE_ISLAND
|
||||
[2] = 0x6f8, -- TWO_ISLAND
|
||||
[3] = 0x6f0, -- THREE_ISLAND
|
||||
[4] = 0x6e0, -- FOUR_ISLAND
|
||||
[5] = 0x4c0, -- FIVE_ISLAND
|
||||
[6] = 0x400, -- SIX_ISLAND
|
||||
[7] = 0x440, -- SEVEN_ISLAND
|
||||
[8] = 0x7ff, -- CINNABAR_ISLAND
|
||||
[9] = 0x6e0, -- NAVEL_ROCK
|
||||
[10] = 0x000, -- BIRTH_ISLAND
|
||||
}
|
||||
|
||||
local CACHE_ROOT = "data/generated/gba/seagallop"
|
||||
|
||||
Seagallop._assets = nil
|
||||
Seagallop._active = false
|
||||
Seagallop._run = nil
|
||||
|
||||
function Seagallop.directionOfTravel(originId, destId)
|
||||
originId = tonumber(originId) or 0
|
||||
destId = tonumber(destId) or 0
|
||||
local mask = TRAVEL_DIRECTIONS[originId]
|
||||
if not mask then return DIRN_EASTBOUND end
|
||||
return (math.floor(mask / (2 ^ destId)) % 2 == 1) and DIRN_EASTBOUND or DIRN_WESTBOUND
|
||||
end
|
||||
|
||||
local function read_bytes(rel)
|
||||
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
|
||||
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
|
||||
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 home = os.getenv("HOME") or ""
|
||||
local candidates = {
|
||||
rel,
|
||||
"data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")),
|
||||
home .. "/.local/share/love/pokemon-love2d/firered/" .. rel,
|
||||
home .. "/.local/share/love/pokemon-love2d/leafgreen/" .. rel,
|
||||
home .. "/.local/share/love/pokemon-love2d/" .. rel,
|
||||
}
|
||||
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(raw, w, h)
|
||||
if not (love and love.image and love.graphics) then return nil end
|
||||
if not raw or #raw < w * h * 4 then return nil end
|
||||
local ok, imgData = pcall(love.image.newImageData, w, h, "rgba8", raw)
|
||||
if not ok or not imgData then return nil end
|
||||
local img = love.graphics.newImage(imgData)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
if img.setWrap then img:setWrap("repeat", "repeat") end
|
||||
return img
|
||||
end
|
||||
|
||||
local function loadAssets()
|
||||
if Seagallop._assets then return Seagallop._assets end
|
||||
|
||||
local wbRaw = read_bytes(CACHE_ROOT .. "/wb.rgba")
|
||||
local ebRaw = read_bytes(CACHE_ROOT .. "/eb.rgba")
|
||||
local ferryRaw = read_bytes(CACHE_ROOT .. "/ferry.rgba")
|
||||
local wakeRaw = read_bytes(CACHE_ROOT .. "/wake.rgba")
|
||||
|
||||
local wbImg = rgba_to_image(wbRaw, 256, 256)
|
||||
local ebImg = rgba_to_image(ebRaw, 256, 256)
|
||||
local ferryImg = rgba_to_image(ferryRaw, 64, 40)
|
||||
local wakeImg = rgba_to_image(wakeRaw, 32, 128)
|
||||
|
||||
local wakeQuads = nil
|
||||
if wakeImg and love and love.graphics and love.graphics.newQuad then
|
||||
wakeQuads = {
|
||||
love.graphics.newQuad(0, 0, 32, 32, 32, 128),
|
||||
love.graphics.newQuad(0, 32, 32, 32, 32, 128),
|
||||
love.graphics.newQuad(0, 64, 32, 32, 32, 128),
|
||||
}
|
||||
end
|
||||
|
||||
Seagallop._assets = {
|
||||
wb = wbImg,
|
||||
eb = ebImg,
|
||||
ferry = ferryImg,
|
||||
wake = wakeImg,
|
||||
wakeQuads = wakeQuads,
|
||||
}
|
||||
return Seagallop._assets
|
||||
end
|
||||
|
||||
function Seagallop.start(originId, destId, onWarp, onDone)
|
||||
local dir = Seagallop.directionOfTravel(originId, destId)
|
||||
local run = {
|
||||
origin = originId,
|
||||
dest = destId,
|
||||
direction = dir,
|
||||
onWarp = onWarp,
|
||||
onDone = onDone,
|
||||
tick = 0,
|
||||
accum = 0,
|
||||
bgX = 0,
|
||||
ferryX = (dir == DIRN_EASTBOUND) and 0 or 240,
|
||||
ferryY = 92,
|
||||
wakes = {},
|
||||
state = "running",
|
||||
}
|
||||
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
if okA and Audio and Audio.playSe then
|
||||
pcall(Audio.playSe, SE_SHIP)
|
||||
end
|
||||
|
||||
Seagallop._run = run
|
||||
Seagallop._active = true
|
||||
loadAssets()
|
||||
|
||||
-- pokefirered/src/seagallop.c:229: Fade in from black as cutscene starts
|
||||
Fade.begin(Fade.MODE.FROM_BLACK, 0.3)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function Seagallop.isActive()
|
||||
return Seagallop._active and Seagallop._run ~= nil
|
||||
end
|
||||
|
||||
function Seagallop.stop()
|
||||
Seagallop._active = false
|
||||
Seagallop._run = nil
|
||||
end
|
||||
|
||||
local function stepTick(run)
|
||||
run.tick = run.tick + 1
|
||||
|
||||
-- 6.0 pixels per tick for water background (0x600 8.8 fixed-point displacement)
|
||||
-- 3.0 pixels per tick for ferry (48 subpixels: 48 >> 4)
|
||||
if run.direction == DIRN_EASTBOUND then
|
||||
run.bgX = (run.bgX + 6.0) % 256
|
||||
run.ferryX = run.ferryX + 3.0
|
||||
else
|
||||
run.bgX = (run.bgX - 6.0) % 256
|
||||
run.ferryX = run.ferryX - 3.0
|
||||
end
|
||||
|
||||
-- Spawn wake every 5 ticks
|
||||
if run.tick % 5 == 0 then
|
||||
table.insert(run.wakes, {
|
||||
x = run.ferryX,
|
||||
y = run.ferryY,
|
||||
frame = 1,
|
||||
tick = 0,
|
||||
})
|
||||
end
|
||||
|
||||
-- Update wake animation (20 frames, 20 frames, 15 frames)
|
||||
local i = 1
|
||||
while i <= #run.wakes do
|
||||
local w = run.wakes[i]
|
||||
w.tick = w.tick + 1
|
||||
if w.tick < 20 then
|
||||
w.frame = 1
|
||||
elseif w.tick < 40 then
|
||||
w.frame = 2
|
||||
elseif w.tick < 55 then
|
||||
w.frame = 3
|
||||
else
|
||||
table.remove(run.wakes, i)
|
||||
i = i - 1
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- pokefirered/src/seagallop.c:286
|
||||
if run.tick >= CROSSING_FRAMES and run.state == "running" then
|
||||
run.state = "fading"
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
if okA and Audio and Audio.fadeOutBgm then
|
||||
pcall(Audio.fadeOutBgm, 4)
|
||||
end
|
||||
Fade.begin(Fade.MODE.TO_BLACK, 0.35, function()
|
||||
run.state = "warping"
|
||||
if Audio and Audio.playSe then
|
||||
pcall(Audio.playSe, SE_EXIT)
|
||||
end
|
||||
if run.onWarp then
|
||||
run.onWarp()
|
||||
end
|
||||
Fade.begin(Fade.MODE.FROM_BLACK, 0.35, function()
|
||||
run.state = "done"
|
||||
Seagallop.stop()
|
||||
if run.onDone then
|
||||
run.onDone()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function Seagallop.update(dt)
|
||||
local run = Seagallop._run
|
||||
if not run then return end
|
||||
|
||||
run.accum = run.accum + (dt or (1 / 60))
|
||||
local maxTicks = 10
|
||||
while run.accum >= (1 / 60) and maxTicks > 0 do
|
||||
run.accum = run.accum - (1 / 60)
|
||||
maxTicks = maxTicks - 1
|
||||
stepTick(run)
|
||||
end
|
||||
end
|
||||
|
||||
function Seagallop.draw()
|
||||
local run = Seagallop._run
|
||||
if not run then return end
|
||||
local assets = loadAssets()
|
||||
|
||||
local cw = (love.graphics.getWidth and love.graphics.getWidth()) or W
|
||||
local ch = (love.graphics.getHeight and love.graphics.getHeight()) or H
|
||||
local maxW = math.max(cw, W, 800)
|
||||
local maxH = math.max(ch, H, 600)
|
||||
|
||||
-- Solid black full-screen background
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", -maxW, -maxH, maxW * 3, maxH * 3)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
-- GBA WIN0 letterbox scissor (Y: 24 to 136)
|
||||
love.graphics.setScissor(0, 24, W, 112)
|
||||
|
||||
if assets and assets.wb and assets.eb then
|
||||
local bg = (run.direction == DIRN_EASTBOUND) and assets.eb or assets.wb
|
||||
local ox = math.floor(run.bgX)
|
||||
local startX = -ox
|
||||
while startX > 0 do startX = startX - 256 end
|
||||
for x = startX, W, 256 do
|
||||
love.graphics.draw(bg, x, 0)
|
||||
end
|
||||
|
||||
-- Wakes
|
||||
if assets.wake and assets.wakeQuads then
|
||||
local scaleX = (run.direction == DIRN_EASTBOUND) and -1 or 1
|
||||
for _, w in ipairs(run.wakes) do
|
||||
local q = assets.wakeQuads[w.frame] or assets.wakeQuads[1]
|
||||
love.graphics.draw(assets.wake, q, math.floor(w.x), math.floor(w.y), 0, scaleX, 1, 16, 16)
|
||||
end
|
||||
end
|
||||
|
||||
-- Ferry
|
||||
if assets.ferry then
|
||||
local scaleX = (run.direction == DIRN_EASTBOUND) and -1 or 1
|
||||
love.graphics.draw(assets.ferry, math.floor(run.ferryX), math.floor(run.ferryY), 0, scaleX, 1, 32, 20)
|
||||
end
|
||||
else
|
||||
love.graphics.setColor(0.18, 0.44, 0.75, 1)
|
||||
love.graphics.rectangle("fill", 0, 24, W, 112)
|
||||
end
|
||||
|
||||
love.graphics.setScissor()
|
||||
|
||||
-- Top and bottom black letterbox bars (and side borders)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", -maxW, -maxH, maxW * 3, maxH + 24)
|
||||
love.graphics.rectangle("fill", -maxW, 136, maxW * 3, maxH * 2)
|
||||
love.graphics.rectangle("fill", -maxW, 0, maxW, H)
|
||||
love.graphics.rectangle("fill", W, 0, maxW, H)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Seagallop
|
||||
@@ -123,6 +123,11 @@ function UiPass.drawUi()
|
||||
BattleTransition.draw()
|
||||
end
|
||||
|
||||
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
|
||||
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
|
||||
tryDraw(SeagallopUi)
|
||||
end
|
||||
|
||||
local okF, Fade = pcall(require, "src.ui.game3.fade")
|
||||
local isNamingOpen = okN and Naming.isOpen and Naming.isOpen()
|
||||
local okR, RegionMap = pcall(require, "src.ui.game3.region_map")
|
||||
|
||||
@@ -121,8 +121,8 @@ local function loadExtractorWithStubs(pokeRun, sectionsRun)
|
||||
for _, sibling in ipairs({
|
||||
"items_extract", "pokedex_chrome_extract", "storage_chrome_extract",
|
||||
"text_chrome_extract", "trainer_card_extract", "seagallop_extract",
|
||||
"cave_transition_extract", "ingame_trades_extract", "union_room_classes_extract",
|
||||
"map_preview_extract",
|
||||
"cave_transition_extract", "weather_extract", "ingame_trades_extract",
|
||||
"union_room_classes_extract", "map_preview_extract",
|
||||
}) do
|
||||
package.loaded["src.import.gba." .. sibling] = { run = function() return true end }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env luajit
|
||||
-- Tests ladder warp behaviors, arrival facing, and prevention of bump-warp loops.
|
||||
|
||||
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 function finish()
|
||||
if failed > 0 then
|
||||
print("[test] FAILED " .. failed)
|
||||
os.exit(1)
|
||||
end
|
||||
print("[test] all passed")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
require("src.core.GameVersion").set("firered")
|
||||
|
||||
local gfx = setmetatable({}, { __index = function() return function() end end })
|
||||
gfx.newQuad = function() return {} end
|
||||
_G.love = { graphics = gfx }
|
||||
|
||||
package.loaded["src.core.game3.audio"] = {
|
||||
install = function() return true end,
|
||||
playSe = function() end,
|
||||
playSong = function() end,
|
||||
playFanfare = function() end,
|
||||
stopAll = function() end,
|
||||
stopSurfMusic = function() end,
|
||||
waitSe = function(_, cb) if cb then cb() end end,
|
||||
}
|
||||
|
||||
local Cache = require("tests.game3_cache")
|
||||
local cacheRoot = Cache.mount("scripts/events.lua", { native = true })
|
||||
if not cacheRoot then
|
||||
print("[skip] game3_ladder_warp_test: " .. tostring(Cache.reason))
|
||||
finish()
|
||||
end
|
||||
|
||||
local Dataset = require("src.core.game3.dataset")
|
||||
local game = { data = {} }
|
||||
Dataset.hydrate(game)
|
||||
|
||||
local Map = require("src.core.game3.map")
|
||||
local Collision = require("src.core.game3.collision")
|
||||
local Player = require("src.core.game3.player")
|
||||
local Warp = require("src.core.game3.warp")
|
||||
|
||||
local mapId = "FR_FIVE_ISLAND_LOST_CAVE_ROOM1"
|
||||
local def = game.data.maps[mapId]
|
||||
check(def ~= nil, "FR_FIVE_ISLAND_LOST_CAVE_ROOM1 exists")
|
||||
|
||||
pcall(Map.ensureMidLayout, game, mapId, def)
|
||||
Collision.bindMap(game, mapId, def)
|
||||
game.currentMap = mapId
|
||||
|
||||
-- Ladder is at (8, 2) with MB_LADDER (0x61)
|
||||
local beh = Collision.behavior(8, 2)
|
||||
check(beh == 0x61, "tile (8,2) is MB_LADDER")
|
||||
check(Collision.isLadder(beh) == true, "tile (8,2) is ladder")
|
||||
check(Collision.warpAt(8, 2) ~= nil, "tile (8,2) has a warp defined")
|
||||
|
||||
-- Wall above ladder is at (8, 1) and is solid tile (coll = 7)
|
||||
local okUp, whyUp = Collision.canEnter(game, 8, 1, { fromX = 8, fromY = 2, dir = "up" })
|
||||
check(okUp == false, "(8,1) above ladder is solid wall")
|
||||
|
||||
-- 1. Arrival facing on ladder
|
||||
local arrivalFace = Collision.destArrivalFacing(game, mapId, 8, 2, "up")
|
||||
check(arrivalFace == "down", "arrival facing on ladder is down (was " .. tostring(arrivalFace) .. ")")
|
||||
|
||||
-- 2. Standing on ladder at (8, 2) facing down
|
||||
Player.cellX = 8
|
||||
Player.cellY = 2
|
||||
Player.facing = "up"
|
||||
Player.moving = false
|
||||
Player.turnTimer = 0
|
||||
|
||||
-- 3. Bumping solid wall (pressing UP) while on ladder tile MUST NOT warp!
|
||||
local moveRes, moveWhy = Player.tryMove("up", game, false)
|
||||
check(moveRes == "blocked",
|
||||
"pressing UP into cave wall while on ladder returns blocked (got " .. tostring(moveRes) .. ", why=" .. tostring(moveWhy) .. ")")
|
||||
|
||||
-- 4. Stepping DOWN off the ladder into the open cave floor at (8, 3)
|
||||
Player.facing = "down"
|
||||
local okDown = Collision.canEnter(game, 8, 3, { fromX = 8, fromY = 2, dir = "down" })
|
||||
check(okDown == true, "(8,3) below ladder is walkable")
|
||||
|
||||
local moveDown = Player.tryMove("down", game, false)
|
||||
check(moveDown == "step", "pressing DOWN on ladder steps off the ladder (got " .. tostring(moveDown) .. ")")
|
||||
|
||||
finish()
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Tests for Renewable Hidden Items system (Treasure Beach, Berry Forest, etc.)
|
||||
|
||||
local Renewable = require("src.core.game3.renewable_hidden_items")
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local FlagsTable = require("src.core.game3.scripting.flags_table")
|
||||
|
||||
local tests = {}
|
||||
|
||||
function tests.test_exclusion_zone_and_step_counter()
|
||||
local ctx = { vars = {}, flags = {} }
|
||||
|
||||
-- Step in normal map (e.g. Pallet Town, group 3, map 0) -> increments counter
|
||||
Renewable.onStep(ctx, 3, 0, "PALLET_TOWN")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 1, "Counter should be 1 after 1 step in non-renewable map")
|
||||
|
||||
Renewable.onStep(ctx, 3, 0, "PALLET_TOWN")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter should be 2 after 2 steps in non-renewable map")
|
||||
|
||||
-- Step inside Treasure Beach (Group 3, Map 46) -> MUST NOT increment counter (Exclusion zone)
|
||||
Renewable.onStep(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter must NOT increment while walking inside Treasure Beach")
|
||||
|
||||
-- Step inside Berry Forest (Group 1, Map 109) -> MUST NOT increment counter
|
||||
Renewable.onStep(ctx, 1, 109, "SEVII_THREE_ISLAND_BERRY_FOREST")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter must NOT increment while walking inside Berry Forest")
|
||||
end
|
||||
|
||||
function tests.test_regeneration_on_map_enter()
|
||||
local ctx = { vars = {}, flags = {} }
|
||||
|
||||
-- Set step counter to 1499 (< 1500)
|
||||
Flags.setVar(ctx, nil, 0x4023, 1499)
|
||||
|
||||
-- Enter Treasure Beach -> should NOT regenerate yet
|
||||
local regenerated = Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH")
|
||||
assert(not regenerated, "Should not regenerate under 1500 steps")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 1499, "Counter should stay 1499")
|
||||
|
||||
-- Accumulate to 1500
|
||||
Flags.setVar(ctx, nil, 0x4023, 1500)
|
||||
|
||||
-- Enter a non-renewable map -> should NOT regenerate
|
||||
local nonRen = Renewable.tryRegenerate(ctx, 3, 0, "PALLET_TOWN")
|
||||
assert(not nonRen, "Non-renewable map should not trigger regeneration")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 1500, "Counter should remain 1500 when entering non-renewable map")
|
||||
|
||||
-- Test RNG roll for Rare tier (roll = 95 >= 90)
|
||||
regenerated = Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH", function() return 95 end)
|
||||
assert(regenerated, "Entering Treasure Beach with >= 1500 steps should trigger regeneration")
|
||||
assert(Flags.getVar(ctx, nil, 0x4023) == 0, "Step counter should reset to 0 after regeneration")
|
||||
|
||||
-- In Treasure Beach rare tier: Ultra Ball, Star Piece, Big Pearl should be cleared (spawned)
|
||||
local starPieceFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE
|
||||
local bigPearlFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL
|
||||
assert(Flags.getFlag(ctx, nil, starPieceFlag) == false, "Rare Star Piece should be spawned (flag false)")
|
||||
assert(Flags.getFlag(ctx, nil, bigPearlFlag) == false, "Rare Big Pearl should be spawned (flag false)")
|
||||
|
||||
-- Test RNG roll for Common tier (roll = 30 < 60)
|
||||
Flags.setVar(ctx, nil, 0x4023, 1500)
|
||||
Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH", function() return 30 end)
|
||||
-- Rare items should now be reset to hidden/collected (flag true)
|
||||
assert(Flags.getFlag(ctx, nil, starPieceFlag) == true, "Star piece should be hidden in common roll (flag true)")
|
||||
assert(Flags.getFlag(ctx, nil, bigPearlFlag) == true, "Big pearl should be hidden in common roll (flag true)")
|
||||
-- Common ultra ball should be spawned (flag false)
|
||||
local ultraBallFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL
|
||||
assert(Flags.getFlag(ctx, nil, ultraBallFlag) == false, "Common Ultra Ball should be spawned (flag false)")
|
||||
end
|
||||
|
||||
return tests
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env luajit
|
||||
|
||||
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 function done()
|
||||
if failed > 0 then
|
||||
print("[test] FAILED " .. failed)
|
||||
os.exit(1)
|
||||
end
|
||||
print("[test] all passed")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local Roamer = require("src.core.game3.roamer")
|
||||
local Audio = require("src.core.game3.audio")
|
||||
local Schema = require("src.core.game3.save_schema_firered")
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local Cache = require("tests.game3_cache")
|
||||
Cache.mount("scripts/events.lua", { native = true })
|
||||
|
||||
-- 1. Starter Species Mapping
|
||||
check(Roamer.speciesForStarter(0) == 244, "Bulbasaur (0) -> Entei (244)")
|
||||
check(Roamer.speciesForStarter(1) == 243, "Squirtle (1) -> Raikou (243)")
|
||||
check(Roamer.speciesForStarter(2) == 245, "Charmander (2) -> Suicune (245)")
|
||||
|
||||
-- 2. Fixed IV Generation (Modern 0-31 IVs, not truncated to 0 or max 7)
|
||||
local ivCheckOk = true
|
||||
for _ = 1, 50 do
|
||||
local mon = Roamer.generateMon(243, 50)
|
||||
if type(mon.ivs) ~= "table" then
|
||||
ivCheckOk = false
|
||||
break
|
||||
end
|
||||
for stat, val in pairs(mon.ivs) do
|
||||
if val < 0 or val > 31 then
|
||||
ivCheckOk = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check(ivCheckOk, "Roamer generates full 0-31 IVs across all 6 stats without truncation")
|
||||
|
||||
-- 3. Audio Override
|
||||
check(Audio.legendaryBattleSong(243) == 339, "Raikou uses Legendary Battle BGM (339)")
|
||||
check(Audio.legendaryBattleSong(244) == 339, "Entei uses Legendary Battle BGM (339)")
|
||||
check(Audio.legendaryBattleSong(245) == 339, "Suicune uses Legendary Battle BGM (339)")
|
||||
|
||||
-- 4. Initialization via InitRoamer
|
||||
local session = {
|
||||
flags = {},
|
||||
vars = {
|
||||
[0x4031] = 2, -- VAR_STARTER_MON = Charmander
|
||||
},
|
||||
party = {
|
||||
{ species = 6, level = 50, hp = 0, maxHp = 100 }, -- fainted Charizard lead
|
||||
{ species = 150, level = 100, hp = 300, maxHp = 300 }, -- conscious Mewtwo
|
||||
},
|
||||
}
|
||||
|
||||
local okInit = Roamer.init(session, 2)
|
||||
check(okInit == true, "Roamer.init succeeds")
|
||||
check(session.roamer ~= nil and session.roamer.active == true, "session.roamer initialized and active")
|
||||
check(session.roamer.species == 245, "Roamer species is Suicune (245)")
|
||||
check(session.roamer.level == 50, "Roamer level is 50")
|
||||
check(session.roamer.map ~= nil and #session.roamer.map > 0, "Roamer assigned initial Kanto route: " .. tostring(session.roamer.map))
|
||||
|
||||
-- 5. Adjacency Movement vs Warp Jump
|
||||
local currentMap = session.roamer.map
|
||||
local adjList = Roamer.ADJACENCY[currentMap]
|
||||
check(adjList and #adjList > 0, "Current roamer route has valid adjacency list")
|
||||
|
||||
-- Step movement
|
||||
Roamer.move(session, "map_transition")
|
||||
local newMap = session.roamer.map
|
||||
local isAdjOrSame = (newMap == currentMap)
|
||||
if not isAdjOrSame and adjList then
|
||||
for _, m in ipairs(adjList) do
|
||||
if m == newMap then isAdjOrSame = true break end
|
||||
end
|
||||
end
|
||||
check(isAdjOrSame, "Roamer.move('map_transition') only moves to adjacent route or stays")
|
||||
|
||||
-- Warp jump (Fly / Teleport / Whiteout)
|
||||
local jumped = false
|
||||
for _ = 1, 20 do
|
||||
Roamer.move(session, "warp_random")
|
||||
if session.roamer.map ~= newMap then
|
||||
jumped = true
|
||||
break
|
||||
end
|
||||
end
|
||||
check(jumped, "Roamer.move('warp_random') randomizes route")
|
||||
|
||||
-- 6. Encounter Rate Gating & Fainted Lead Repel Trick
|
||||
session.roamer.map = "FR_ROUTE2"
|
||||
session.vars[0x4020] = 250 -- VAR_REPEL_STEP_COUNT = 250 (Repel is active)
|
||||
|
||||
-- Scenario A: Lead slot 1 is Level 50 (fainted), slot 2 is Level 100
|
||||
-- Repel checks Slot 1 (Lv 50 <= Roamer Lv 50) -> Encounter MUST pass!
|
||||
local encA = Roamer.tryEncounter(session, "FR_ROUTE2", "grass")
|
||||
check(encA ~= nil and encA.roamer == true and encA.species == 245, "Fainted Slot 1 Lv 50 passes Repel gate")
|
||||
|
||||
-- Scenario B: Lead slot 1 is Level 51 (fainted)
|
||||
-- Repel checks Slot 1 (Lv 51 > Roamer Lv 50) -> Repelled!
|
||||
session.party[1].level = 51
|
||||
local encB = Roamer.tryEncounter(session, "FR_ROUTE2", "grass")
|
||||
check(encB == nil, "Lead Slot 1 Lv 51 triggers Repel and repels roamer")
|
||||
|
||||
-- Reset lead level to 50
|
||||
session.party[1].level = 50
|
||||
|
||||
-- Scenario C: Wrong map
|
||||
local encC = Roamer.tryEncounter(session, "FR_ROUTE3", "grass")
|
||||
check(encC == nil, "Different map returns no encounter")
|
||||
|
||||
-- 7. Battle End Handling & Roar Despawn Prevention
|
||||
local foeState = {
|
||||
hp = 60,
|
||||
maxHp = 150,
|
||||
status = "paralysis",
|
||||
statusNum = 0x40,
|
||||
}
|
||||
|
||||
-- Flee / Roar end
|
||||
local prevLocation = session.roamer.map
|
||||
Roamer.onBattleEnd(session, foeState, "fled", "roar")
|
||||
check(session.roamer.active == true, "Roar / Flee keeps roamer ACTIVE (Roar despawn bug fixed)")
|
||||
check(session.roamer.hp == 60, "Roamer HP damage persisted (60)")
|
||||
check(session.roamer.status == "paralysis", "Roamer status condition persisted (paralysis)")
|
||||
|
||||
-- Defeated
|
||||
foeState.hp = 0
|
||||
Roamer.onBattleEnd(session, foeState, "win", "faint")
|
||||
check(session.roamer.active == false, "Defeating roamer sets active to false")
|
||||
|
||||
-- 8. Save/Load Schema Persistence
|
||||
session.roamer = {
|
||||
active = true,
|
||||
species = 243,
|
||||
level = 50,
|
||||
hp = 85,
|
||||
maxHp = 160,
|
||||
status = 0,
|
||||
statusNum = 0,
|
||||
pid = 0x12345678,
|
||||
ivs = { hp = 31, atk = 30, def = 29, spe = 28, spa = 27, spd = 26 },
|
||||
map = "FR_ROUTE1",
|
||||
}
|
||||
|
||||
local saved = Schema.toSaveTable(session)
|
||||
check(saved.roamer ~= nil and saved.roamer.species == 243, "Save schema exports roamer")
|
||||
|
||||
local loadedSession = Schema.fromSaveTable(saved)
|
||||
check(loadedSession.roamer ~= nil, "Save schema restores roamer")
|
||||
check(loadedSession.roamer.species == 243, "Restored roamer has correct species")
|
||||
check(loadedSession.roamer.hp == 85, "Restored roamer has correct HP")
|
||||
check(loadedSession.roamer.ivs.hp == 31, "Restored roamer has correct IVs")
|
||||
-- 9. Turn Order Against Roaming Flee (Speed / Priority checks matching pokefirered)
|
||||
local Engine = require("src.core.game3.battle.engine")
|
||||
local Adapter = require("src.core.game3.battle.adapter")
|
||||
|
||||
local pMon = { species = 150, level = 50, speed = 200, moves = { 33 }, pp = { 30 }, maxHp = 200, hp = 200 }
|
||||
local eMon = { species = 244, level = 50, speed = 100, moves = { 52 }, pp = { 25 }, maxHp = 175, hp = 175, roamer = true }
|
||||
local battleSt = {
|
||||
player = { mon = pMon, side = "player", stages = {} },
|
||||
enemy = { mon = eMon, side = "enemy", stages = {} },
|
||||
wild = true,
|
||||
roamer = true,
|
||||
}
|
||||
local battleAd = Adapter.new(battleSt, function() end)
|
||||
local pAct = { kind = "move", move = 33, slot = 1, user = battleSt.player, battler = 0 }
|
||||
local eAct = { kind = "run", user = battleSt.enemy, battler = 1 }
|
||||
|
||||
-- Faster player attacks before roamer flees
|
||||
local actFaster = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
|
||||
check(actFaster[1].user.side == "player", "Faster player attacks before roamer flees")
|
||||
check(actFaster[2].user.side == "enemy" and actFaster[2].kind == "run", "Roamer flees second on turn")
|
||||
|
||||
-- Slower player
|
||||
pMon.speed = 50
|
||||
local actSlower = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
|
||||
check(actSlower[1].user.side == "enemy" and actSlower[1].kind == "run", "Faster roamer flees before slower player")
|
||||
|
||||
-- Priority move on slower player
|
||||
pMon.moves[1] = 98 -- Quick Attack (+1)
|
||||
pAct.move = 98
|
||||
local actPri = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
|
||||
check(actPri[1].user.side == "player", "Priority move on slower player attacks before roamer flees")
|
||||
|
||||
-- Bag item (Ball)
|
||||
local bAct = { kind = "bag", itemId = 1, user = battleSt.player, battler = 0 }
|
||||
local _, bagMeta = Engine.planTurnFromActions(battleSt, battleAd, bAct, eAct)
|
||||
check(bagMeta and bagMeta.kind == "bag", "Bag action executes before roamer flees")
|
||||
|
||||
-- 10. Trapping Mechanics vs Roaming Flee
|
||||
local Ai = require("src.core.game3.battle.ai")
|
||||
|
||||
-- Untrapped roamer chooses run
|
||||
battleSt.enemy.escapePrevention = nil
|
||||
battleSt.enemy.expTrapped = nil
|
||||
battleSt.enemy.expTrapTurns = nil
|
||||
local untrappedAct = Ai.chooseMove(battleSt, { battler = 1 })
|
||||
check(untrappedAct and untrappedAct.kind == "run", "Untrapped roamer chooses flee")
|
||||
|
||||
-- Mean Look / Escape Prevention
|
||||
battleSt.enemy.escapePrevention = true
|
||||
battleSt.enemy.expTrapped = true
|
||||
local trappedAct = Ai.chooseMove(battleSt, { battler = 1 })
|
||||
check(trappedAct and trappedAct.kind == "move", "Mean Look trapped roamer cannot flee and chooses attack move")
|
||||
check(Engine.canSwitch(battleSt, battleAd, battleSt.enemy) == false, "Engine.canSwitch returns false for trapped roamer")
|
||||
|
||||
-- Shadow Tag
|
||||
battleSt.enemy.escapePrevention = nil
|
||||
battleSt.enemy.expTrapped = nil
|
||||
battleSt.player.ability = "SHADOW_TAG"
|
||||
pMon.ability = "SHADOW_TAG"
|
||||
local shadowTagAct = Ai.chooseMove(battleSt, { battler = 1 })
|
||||
check(shadowTagAct and shadowTagAct.kind == "move", "Shadow Tag prevents roamer from fleeing and forces attack")
|
||||
|
||||
-- Arena Trap (Entei is grounded -> trapped)
|
||||
battleSt.player.ability = "ARENA_TRAP"
|
||||
pMon.ability = "ARENA_TRAP"
|
||||
local arenaTrapAct = Ai.chooseMove(battleSt, { battler = 1 })
|
||||
check(arenaTrapAct and arenaTrapAct.kind == "move", "Arena Trap traps grounded beast and forces attack")
|
||||
|
||||
-- Partial trapping (Wrap / Fire Spin)
|
||||
battleSt.player.ability = "PRESSURE"
|
||||
pMon.ability = "PRESSURE"
|
||||
battleSt.enemy.expTrapTurns = 4
|
||||
battleSt.enemy.wrapped = true
|
||||
local wrapAct = Ai.chooseMove(battleSt, { battler = 1 })
|
||||
check(wrapAct and wrapAct.kind == "move", "Partial trap (Fire Spin / Wrap) prevents roamer flee")
|
||||
|
||||
-- Switch-out releases trap
|
||||
battleSt.enemy.expTrapTurns = nil
|
||||
battleSt.enemy.wrapped = nil
|
||||
battleSt.enemy.expTrapped = true
|
||||
battleSt.enemy.expTrappedBy = battleSt.player
|
||||
battleSt.playerParty = { pMon, { species = 25, level = 50, hp = 100, maxHp = 100 } }
|
||||
Engine.performSwitch(battleSt, battleAd, "player", 2)
|
||||
Engine.refreshLinks(battleSt)
|
||||
check(battleSt.enemy.expTrapped == nil and battleSt.enemy.expTrappedBy == nil, "Switching out player clears enemy trap")
|
||||
|
||||
done()
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Tests for Seagallop ferry logic, direction matrix, and animation tick simulation
|
||||
|
||||
local Seagallop = require("src.ui.game3.seagallop")
|
||||
local NativeSeagallop = require("src.core.game3.scripting.natives_seagallop")
|
||||
|
||||
local tests = {}
|
||||
|
||||
function tests.test_direction_of_travel()
|
||||
-- From Vermilion (0): all Sevii islands are Eastbound (1)
|
||||
assert(Seagallop.directionOfTravel(0, 1) == 1, "Vermilion -> One Island should be Eastbound")
|
||||
assert(Seagallop.directionOfTravel(0, 2) == 1, "Vermilion -> Two Island should be Eastbound")
|
||||
assert(Seagallop.directionOfTravel(0, 3) == 1, "Vermilion -> Three Island should be Eastbound")
|
||||
assert(Seagallop.directionOfTravel(0, 7) == 1, "Vermilion -> Seven Island should be Eastbound")
|
||||
|
||||
-- From One Island (1): to Vermilion (0) is Westbound (0)
|
||||
assert(Seagallop.directionOfTravel(1, 0) == 0, "One Island -> Vermilion should be Westbound")
|
||||
|
||||
-- From Birth Island (10): 0x000 -> all destinations Westbound (0)
|
||||
assert(Seagallop.directionOfTravel(10, 0) == 0, "Birth Island -> Vermilion should be Westbound")
|
||||
end
|
||||
|
||||
function tests.test_seagallop_numbers()
|
||||
assert(NativeSeagallop.seagallopNumber(8, 0) == 1, "Cinnabar should be Seagallop 1")
|
||||
assert(NativeSeagallop.seagallopNumber(1, 2) == 2, "One -> Two Island should be Seagallop 2")
|
||||
assert(NativeSeagallop.seagallopNumber(4, 5) == 3, "Four -> Five Island should be Seagallop 3")
|
||||
assert(NativeSeagallop.seagallopNumber(6, 7) == 5, "Six -> Seven Island should be Seagallop 5")
|
||||
assert(NativeSeagallop.seagallopNumber(0, 1) == 7, "Vermilion -> Island should be Seagallop 7")
|
||||
assert(NativeSeagallop.seagallopNumber(9, 1) == 10, "Navel Rock -> Island should be Seagallop 10")
|
||||
assert(NativeSeagallop.seagallopNumber(10, 1) == 12, "Birth Island -> Island should be Seagallop 12")
|
||||
end
|
||||
|
||||
function tests.test_simulation_ticks()
|
||||
local warped = false
|
||||
local done = false
|
||||
Seagallop.start(0, 1, function() warped = true end, function() done = true end)
|
||||
assert(Seagallop.isActive(), "Seagallop cutscene should be active")
|
||||
|
||||
-- Update through 10 frames (10/60 seconds)
|
||||
Seagallop.update(10 / 60)
|
||||
local run = Seagallop._run
|
||||
assert(run.tick == 10, "Should have simulated exactly 10 ticks (got " .. tostring(run.tick) .. ")")
|
||||
assert(run.ferryX == 30, "Eastbound ferry should move +3 px per tick (at x=30)")
|
||||
assert(run.bgX == 60, "Eastbound BG should scroll +6 px per tick (at bgX=60)")
|
||||
assert(#run.wakes == 2, "Should have spawned 2 wakes at tick 5 and 10 (got " .. tostring(#run.wakes) .. ")")
|
||||
|
||||
Seagallop.stop()
|
||||
assert(not Seagallop.isActive(), "Seagallop should be inactive after stop")
|
||||
end
|
||||
|
||||
return tests
|
||||
@@ -125,8 +125,8 @@ else
|
||||
check(Collision.arrivalFacing(beh, "left") == want,
|
||||
string.format("landing on 0x%02X faces %s", beh, want))
|
||||
end
|
||||
check(Collision.arrivalFacing(MB.LADDER, "left") == "left",
|
||||
"a ladder keeps the direction the player left with")
|
||||
check(Collision.arrivalFacing(MB.LADDER, "left") == "down",
|
||||
"a ladder lands facing south")
|
||||
check(Collision.arrivalFacing(0x00, "left") == "down",
|
||||
"hasDirectionSet is cleared, so a plain cell faces south")
|
||||
check(Collision.arrivalFacing(MB.FALL, "up") == "down",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Tests for Weather constants, lifecycle, and FieldWeather atmospheric states
|
||||
|
||||
local Weather = require("src.core.game3.weather")
|
||||
local FieldWeather = require("src.core.game3.field_weather")
|
||||
|
||||
local tests = {}
|
||||
|
||||
function tests.test_weather_constants()
|
||||
assert(Weather.NONE == 0, "WEATHER_NONE should be 0")
|
||||
assert(Weather.SUNNY == 2, "WEATHER_SUNNY should be 2")
|
||||
assert(Weather.RAIN == 3, "WEATHER_RAIN should be 3")
|
||||
assert(Weather.FOG_HORIZONTAL == 6, "WEATHER_FOG_HORIZONTAL should be 6")
|
||||
assert(Weather.SHADE == 11, "WEATHER_SHADE should be 11")
|
||||
assert(Weather.FOG == 6, "Weather.FOG alias should be 6")
|
||||
end
|
||||
|
||||
function tests.test_weather_lifecycle()
|
||||
Weather.reset()
|
||||
assert(Weather.get() == Weather.NONE, "Weather should be NONE after reset")
|
||||
assert(not Weather.isActive(), "Weather should be inactive")
|
||||
|
||||
-- Script opcode sequence: setweather -> doweather
|
||||
Weather.set(Weather.FOG_HORIZONTAL)
|
||||
assert(Weather.get() == Weather.NONE, "Weather should remain NONE until doweather is called")
|
||||
Weather.doWeather()
|
||||
assert(Weather.get() == Weather.FOG_HORIZONTAL, "Weather should be FOG_HORIZONTAL after doweather")
|
||||
assert(Weather.isActive(), "Weather should be active")
|
||||
assert(FieldWeather.getWeather() == Weather.FOG_HORIZONTAL, "FieldWeather should be synchronized")
|
||||
|
||||
-- Test suspension (e.g. entering battle/menu)
|
||||
assert(not Weather.isSuspended(), "Weather should not be suspended initially")
|
||||
Weather.suspend()
|
||||
assert(Weather.isSuspended(), "Weather should be suspended during battle")
|
||||
Weather.resume()
|
||||
assert(not Weather.isSuspended(), "Weather should resume after battle")
|
||||
|
||||
-- Test Shade
|
||||
Weather.apply(Weather.SHADE)
|
||||
assert(Weather.get() == Weather.SHADE, "Weather should be SHADE")
|
||||
assert(FieldWeather.getWeather() == Weather.SHADE, "FieldWeather should be SHADE")
|
||||
|
||||
-- Reset back to NONE
|
||||
Weather.reset()
|
||||
assert(Weather.get() == Weather.NONE, "Weather should be reset to NONE")
|
||||
end
|
||||
|
||||
function tests.test_fog_horizontal_scroll()
|
||||
FieldWeather.setWeather(Weather.FOG_HORIZONTAL)
|
||||
assert(FieldWeather._fogScrollOffset == 0, "Initial scroll offset should be 0")
|
||||
|
||||
-- 4 ticks per pixel of scroll
|
||||
for i = 1, 4 do
|
||||
FieldWeather.update(1 / 60)
|
||||
end
|
||||
assert(FieldWeather._fogScrollOffset == 1, "Scroll offset should advance by 1 after 4 ticks")
|
||||
|
||||
for i = 1, 4 do
|
||||
FieldWeather.update(1 / 60)
|
||||
end
|
||||
assert(FieldWeather._fogScrollOffset == 2, "Scroll offset should advance by 2 after 8 ticks")
|
||||
end
|
||||
|
||||
return tests
|
||||
Reference in New Issue
Block a user