Merge pull request #2184 from bryanthaboi/dev

fixes twixes and all around shlixes
This commit is contained in:
bryanthaboi
2026-09-04 10:50:32 -04:00
committed by GitHub
150 changed files with 7618 additions and 593 deletions
+1 -2
View File
@@ -56,8 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do
M[mapId].onStep = function(game, ow, x, y)
for _, h in ipairs(holes) do
if x == h[1] and y == h[2] then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
ow:fallThroughHole(h[3], h[4], h[5], ow.player.facing)
return true
end
end
+16 -15
View File
@@ -1004,8 +1004,7 @@ M.VICTORY_ROAD_3F = {
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
ow:fallThroughHole("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
@@ -1220,20 +1219,22 @@ M.CERULEAN_CITY = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "face_player" },
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" },
{ "jump_if_true", "beaten" },
{ "show_text", "_CeruleanCityRivalPreBattleText" },
-- SaveEndBattleTextPointers (scripts/CeruleanCity.asm:141)
{ "save_end_battle_text", "_CeruleanCityRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL1", 7 },
{ "jump_if_false", "end" },
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" },
{ "show_text", "_CeruleanCityRivalIWentToBillsText" },
{ "walk_npc", 1, { "right", "down", "down", "down",
"down", "down", "down" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "jump", "end" }, -- 12
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
"down", "down", "down" } },
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" },
{ "jump", "end" },
{ "label", "beaten" },
{ "show_text", "_CeruleanCityRivalIWentToBillsText" },
},
},
}
+10 -3
View File
@@ -633,8 +633,7 @@ M.ROUTE_24 = {
end
if not flags.EVENT_GOT_NUGGET then
local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
local function prize()
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
@@ -642,10 +641,18 @@ M.ROUTE_24 = {
end
flags.EVENT_GOT_NUGGET = true
game.stringBuffer = game.data.items.NUGGET.name
-- pokeyellow scripts/Route24.asm:157
-- pokered scripts/Route24.asm:156
local jingle = require("src.core.GameVersion").isYellow()
and "Get_Key_Item" or "Get_Item1"
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end, require("src.render.TextBox").soundOpts(game, jingle))
end
-- scripts/Route24.asm:149-153
push(game, t._Route24CooltrainerM1YouBeatOurContestText, function()
push(game, t._Route24CooltrainerM1YouJustEarnedAPrizeText, prize)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return
end
+42 -29
View File
@@ -517,7 +517,7 @@ M.PEWTER_CITY = {
-- skipped (the blackout rebuilds the map mid-script).
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
if ow.runner:isRunning() then return false end
ow.player.facing = playerFacing
if playerFacing then ow.player.facing = playerFacing end
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
-- takes over and the map theme returns after the victory jingle
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
@@ -622,21 +622,28 @@ local function ceruleanRivalExitDirs(px)
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
-- CeruleanCityMovement1 (scripts/CeruleanCity.asm:114)
local CERULEAN_RIVAL_APPROACH = { "down", "down", "down" }
local function ceruleanRivalScene(px)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
{ "move_npc_to", 1, px, py - 1 }, -- 2
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 13 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 11
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" },
-- x 20 (scripts/CeruleanCity.asm:84-91)
{ "place_npc", 1, px, 2, "down" },
{ "walk_npc", 1, CERULEAN_RIVAL_APPROACH },
{ "face_object", 1, "down" }, -- CeruleanCityFaceRivalScript
{ "show_text", "_CeruleanCityRivalPreBattleText" },
-- SaveEndBattleTextPointers (scripts/CeruleanCity.asm:141)
{ "save_end_battle_text", "_CeruleanCityRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL1", 7 },
{ "jump_if_false", "leave" },
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" },
{ "show_text", "_CeruleanCityRivalIWentToBillsText" },
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, ceruleanRivalExitDirs(px) },
{ "play_default_music" }, -- scripts/CeruleanCity.asm:230
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 13
{ "label", "leave" },
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" },
}
end
@@ -698,7 +705,7 @@ M.CERULEAN_CITY = {
end
if not f.EVENT_BEAT_CERULEAN_RIVAL
and inCoords({ { 20, 6 }, { 21, 6 } }, x, y) then
return runAmbush(game, ow, ceruleanRivalScene(x, y), "up")
return runAmbush(game, ow, ceruleanRivalScene(x), "up")
end
return false
end,
@@ -917,23 +924,29 @@ M.SS_ANNE_2F = {
if game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL then return false end
if not inCoords({ { 36, 8 }, { 37, 8 } }, x, y) then return false end
local onLeft = x == 36
return runAmbush(game, ow, {
{ "show_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 1
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
local rows = {
{ "show_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" },
{ "move_npc_to", 2, 36, onLeft and 7 or 8 },
{ "face_object", 2, onLeft and "down" or "right" },
{ "show_text", "_SSAnne2FRivalText" },
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
{ "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL2", 1 },
{ "jump_if_false", "leave" },
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" },
{ "show_text", "_SSAnne2FRivalCutMasterText" },
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) },
{ "play_default_music" }, -- scripts/SSAnne2F.asm:175
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 13
}, onLeft and "up" or "left")
{ "label", "leave" },
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" },
}
-- the wXCoord == 37 branch (scripts/SSAnne2F.asm:73-77)
if not onLeft then
table.insert(rows, 4, { "face_player_dir", "left" })
end
return runAmbush(game, ow, rows)
end,
}
+1 -2
View File
@@ -122,8 +122,7 @@ local MANSION_HOLES = {
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
for _, h in ipairs(MANSION_HOLES) do
if x == h[1] and y == h[2] then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
ow:fallThroughHole(h[3], h[4], h[5], ow.player.facing)
return true
end
end
+6 -3
View File
@@ -21,7 +21,8 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "show_text", "MelanieText1" }
rows[#rows + 1] = { "ask", "MelanieText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
-- engine/events/give_pokemon.asm:45-46
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10, false, true }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
-- Event and HideObject before MelanieText3: give_pokemon's nickname
-- prompt and the received text both yield, and a script that dies
@@ -64,7 +65,8 @@ M.ROUTE_24 = {
{ "jump_if_true", "after" },
{ "ask", "_Route24DamianText1" },
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
-- engine/events/give_pokemon.asm:45-46
{ "give_pokemon", "CHARMANDER", 10, false, true },
{ "jump_if_false", "end" },
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
-- after Route24Text_515e3, where nothing in between can fail): the
@@ -96,7 +98,8 @@ M.VERMILION_CITY = {
else
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
-- engine/events/give_pokemon.asm:45-46
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10, false, true }
rows[#rows + 1] = { "jump_if_false", "end" }
-- event before the received text, as above (#426)
rows[#rows + 1] = { "set_flag",
+1
View File
@@ -35,3 +35,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround)
* **Widescreen battle layout** on Gold, Silver and Crystal: BATTLE LAYOUT -> WIDE spreads the HUDs and bottom windows across a 304-wide screen
+23 -12
View File
@@ -1305,7 +1305,8 @@ function BattleState:startMessage(item)
self.current = item
self.lines = {}
self.total = 0
local text = item.text or ""
-- constants/charmap.asm:19-20
local text = require("src.render.TextBox").strip(item.text or "")
local pos, cont = 1, false
while true do
local npos = text:find("[\n\v]", pos)
@@ -1982,7 +1983,7 @@ function BattleState:enter()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
self:queueEnemySendOutCry(true)
end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
@@ -2025,6 +2026,7 @@ end
-- any pop (finish, script teardown) must silence the alarm loop
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
self.enemyHudPending = nil
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
@@ -3569,6 +3571,17 @@ function BattleState:startGrowIn(battler)
table.insert(self.queue, self.nextInsert, { wait = 12 })
end
-- EnemySendOutFirstMon (core.asm:1432-1435)
-- WaitForSoundToFinish (home/pokemon.asm:145-149)
function BattleState:queueEnemySendOutCry(append)
self.enemyHudPending = true
local fn = function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
self:actNext(function() self.enemyHudPending = nil end)
end
if append then self:act(fn) else self:actNext(fn) end
end
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
function BattleState:starterPikachuSendOut()
@@ -3991,9 +4004,7 @@ function BattleState:executeAction(user, target, action)
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
self:queueEnemySendOutCry(false)
end)
return
end
@@ -4188,13 +4199,14 @@ local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
if msgs.failed then return true end
-- the extracted lines keep the ROM's own trailing blank ("But, it
-- failed! "), so match with it trimmed or a refused status animates
local m = msgs[1]:gsub("%s+$", "")
-- failed! ") and its terminator, so strip both or a refused status animates
local m = require("src.render.TextBox").strip(msgs[1]):gsub("%s+$", "")
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end
if m:find("protected by MIST", 1, true) then return true end
if m:find("Already", 1, true) then return true end
-- engine/battle/effects.asm:46-47
if m:lower():find("already asleep", 1, true) then return true end
return false
end
@@ -4611,6 +4623,7 @@ function BattleState:awardExp()
if mon == self.player.mon then
-- engine/battle/experience.asm:236-239
self.player.badgeExtraBoosts = nil
require("src.battle.Status").bakeOnInflict(self, self.player)
self.player.shownHP = mon.hp
self.player.shownPx = Timing.hpBarPixels(mon.hp,
math.max(1, mon.stats.hp))
@@ -4786,9 +4799,7 @@ function BattleState:enemyMonFainted()
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
self:queueEnemySendOutCry(false)
end)
end)
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
@@ -6539,7 +6550,7 @@ function BattleState:drawHUDs(slide)
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
-- player's ball row with no enemy HUD beside it (#317)
if showStatus and self.enemy and not self.showEnemyTrainer
and not self.enemySendingOut
and not self.enemySendingOut and not self.enemyHudPending
and not self:growInScale(self.enemy) and slide == 0
and not self.introBalls and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
+8 -11
View File
@@ -69,11 +69,6 @@ function Damage.reapplyBadgeBoosts(battler, changedStat)
end
end
-- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status)
end
-- Critical chance test, following CriticalHitTest's shift chain exactly
-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
@@ -213,11 +208,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
-- unmodified attack over the burn-halved battle stat, lifting the
-- penalty until the next stat recompute.
local record = statusRecord(attacker)
local penalty = record and record.statPenalty
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then
atk = math.max(1, math.floor(atk / penalty.div))
end
-- core.asm:6326, effects.asm:634-635
atk = Status.applyPenalty(attacker, atkStat, atk)
-- screens double the effective defense (crits bypass them). The
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
-- swaps the user's own defense in but leaves the screen check
@@ -274,7 +266,12 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
if d == 0 then
-- a 2-3 damage hit at 0.25x floors to zero: the original flags
-- the move as missed rather than dealing a minimum 1
return 0, { crit = false, typeMult = mult, missed = true }
-- engine/battle/core.asm:5169-5176
if ruleset.zeroDamageMiss == false then
d = 1
else
return 0, { crit = false, typeMult = mult, missed = true }
end
end
end
+17 -5
View File
@@ -13,6 +13,7 @@
local Damage = require("src.battle.Damage")
local Logger = require("src.core.Logger")
local Status = require("src.battle.Status")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
@@ -55,9 +56,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
return { romText(battle.data, "_NothingHappenedText", "Nothing happened!") }
end
who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
-- re-baking the burn/para penalty and ending Haze's temporary lift.
-- effects.asm:414-415
local foe = (who == battle.player) and battle.enemy or battle.player
Status.afterStatChange(battle, who, stat, fromEnemy and who or foe)
-- pokered engine/battle/effects.asm:505-506
who.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" and who == battle.player then
@@ -102,10 +104,19 @@ end
local function statusMove(status)
return function(battle, user, target, move)
if target.mon.status then
return { romText(battle.data, "_ButItFailedText", "But, it failed!") }
-- engine/battle/effects.asm:44-47
if status == "SLP" and target.mon.status == "SLP" then
return { romText(battle.data, "_AlreadyAsleepText",
"%s's\nalready asleep!", displayName(target)) }
end
-- effects.asm:51, move_effects/paralyze.asm:12
return { romText(battle.data, "_DidntAffectText",
"It didn't affect\n%s!", displayName(target)) }
end
if status == "PSN" and target.substituteHP then
return { romText(battle.data, "_ButItFailedText", "But, it failed!") }
-- effects.asm:88 .noEffect falls into .didntAffect for POISON_EFFECT
return { romText(battle.data, "_DidntAffectText",
"It didn't affect\n%s!", displayName(target)) }
end
local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC",
@@ -265,6 +276,7 @@ MoveEffects.primary = {
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
-- until the next stat recompute (a stage change or switch-in).
b.hazeStatReset = true
if b.statusPenaltyStacks then b.statusPenaltyStacks = {} end
b.badgeExtraBoosts = nil
end
-- Gen 1 also removes the enemy's major status; if that cured sleep
+83
View File
@@ -167,6 +167,89 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id]
end
-- engine/battle/core.asm:6283
local function penaltyOf(battler)
local record = Status.recordFor(battler.statuses, battler.mon.status)
return record and record.statPenalty
end
local MAX_PENALTY_STACKS = 32
function Status.penaltyStacks(battler, stat)
local p = penaltyOf(battler)
if not p or p.stat ~= stat then return 0 end
local t = battler.statusPenaltyStacks
local n = t and (t[stat] or 0) or (battler.hazeStatReset and 0 or 1)
if type(n) ~= "number" or n ~= n or n < 0 then return 0 end
return math.min(n, MAX_PENALTY_STACKS)
end
function Status.applyPenalty(battler, stat, value)
local p = penaltyOf(battler)
if not p or p.stat ~= stat then return value end
local div = math.max(1, p.div or 1)
for _ = 1, Status.penaltyStacks(battler, stat) do
value = math.max(1, math.floor(value / div))
end
return value
end
-- core.asm:1658
-- experience.asm:237
function Status.bakePenalty(battler)
local t = {}
battler.statusPenaltyStacks = t
local p = penaltyOf(battler)
if p then t[p.stat] = 1 end
end
-- engine/battle/effects.asm:414-415
function Status.clearPenalty(battler, stat)
local t = battler.statusPenaltyStacks
if t then t[stat] = 0 end
end
-- engine/battle/effects.asm:505-506
function Status.stackPenalty(battler)
local t = battler.statusPenaltyStacks
if not t then return end
local p = penaltyOf(battler)
if p then t[p.stat] = (t[p.stat] or 0) + 1 end
end
-- pokered engine/battle/core.asm:6283
function Status.rulesetBakes(ruleset)
return ruleset ~= nil and ruleset.statusPenaltyIsBaked ~= false
end
local function ensureStacks(battle, battler)
if battler.statusPenaltyStacks then return true end
if not Status.rulesetBakes(battle and battle.ruleset) then return false end
Status.bakePenalty(battler)
if battler.hazeStatReset then battler.statusPenaltyStacks = {} end
return true
end
-- move_effects/paralyze.asm:35
function Status.bakeOnInflict(battle, battler)
if not (battler.statusPenaltyStacks
or Status.rulesetBakes(battle and battle.ruleset)) then
return
end
Status.bakePenalty(battler)
end
-- effects.asm:414-415
function Status.afterStatChange(battle, who, stat, nonUser)
if not ensureStacks(battle, who) then return end
if stat ~= "accuracy" and stat ~= "evasion" then
Status.clearPenalty(who, stat)
end
if nonUser and ensureStacks(battle, nonUser) then
Status.stackPenalty(nonUser)
end
end
-- the HUD label for a status id: a mod's patched hudLabel/label if the
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
+2
View File
@@ -41,6 +41,8 @@ function StatusRegistry.inflict(battle, target, status, opts)
return {}
end
target.mon.status = status
-- move_effects/paralyze.asm:35, effects.asm:236
Status.bakeOnInflict(battle, target)
local msgs
local display = displayName(target)
if record and record.onInflict then
+4
View File
@@ -20,6 +20,7 @@
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local Status = require("src.battle.Status")
local romText = require("src.core.RomText")
local TrainerAI = {}
@@ -131,6 +132,9 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then
local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
-- trainer_ai.asm:719 -> effects.asm:414-415
Status.afterStatChange(battle, enemy, stat, battle.player)
enemy.hazeStatReset = nil
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
elseif item == "GUARD_SPEC" then
enemy.mist = true
+2 -10
View File
@@ -15,16 +15,8 @@ local function effectiveSpeed(battler)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
spd = Damage.applyBadgeBoost(battler, "speed", spd)
-- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting
-- the penalty until the next stat recompute.
local record = Status.recordFor(battler.statuses, battler.mon.status)
local penalty = record and record.statPenalty
if penalty and penalty.stat == "speed" and not battler.hazeStatReset then
spd = math.max(1, math.floor(spd / penalty.div))
end
return spd
-- core.asm:6283, effects.asm:414-415
return Status.applyPenalty(battler, "speed", spd)
end
local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 }
+4
View File
@@ -25,4 +25,8 @@ return {
residualAfterMove = true,
-- engine/battle/effects.asm:498,689
badgeBoostReapplyBug = true,
-- engine/battle/core.asm:5169-5176
zeroDamageMiss = true,
-- engine/battle/core.asm:6283,6326 and effects.asm:414-415
statusPenaltyIsBaked = true,
}
+4
View File
@@ -16,4 +16,8 @@ return {
-- Gen 3+ style: poison/burn/leech seed tick in an end-of-round sweep
-- after both sides have moved.
residualAfterMove = false,
-- engine/battle/core.asm:5169-5176
zeroDamageMiss = false,
-- engine/battle/core.asm:6283,6326 and effects.asm:414-415
statusPenaltyIsBaked = false,
}
+1 -1
View File
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
"chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
"skipMove", "hazeStatReset", "badgeExtraBoosts", "statusPenaltyStacks", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
"aiLayer2",
}
+20 -3
View File
@@ -436,6 +436,8 @@ function Game2:openStartMenu()
if self.world and self.world.cancelMapNameSign then
self.world:cancelMapNameSign()
end
-- ../pokecrystal/engine/overworld/events.asm:494-510
if self.world and self.world.player then self.world.player:stopForEvent() end
Screens.push(self, "Gen2StartMenu", {
save = self.save,
onClose = function() self.stack:pop() end,
@@ -854,6 +856,8 @@ end
-- the cart's fixed messages to print, the same way the START handler above
-- is the whole of .MenuReturns for its own button.
function Game2:useSelectItem()
-- ../pokecrystal/engine/overworld/events.asm:494-510
if self.world.player then self.world.player:stopForEvent() end
local outcome, itemId = self.world:useSelectItem()
if outcome == "not_registered" then
-- MayRegisterItemText.
@@ -1723,19 +1727,32 @@ local function battleSurround(stack)
for i = #states, 1, -1 do
local state = states[i]
if state and state.bgMode then
return state:bgMode(), state.BG_WORLD_DIM or 0.55
return state:bgMode(), state.BG_WORLD_DIM or 0.55, state, i
end
end
end
function Game2:paintBattleSurround(w, h)
local mode, dim = battleSurround(self.stack)
local mode, dim, owner, at = battleSurround(self.stack)
if mode ~= "black" and mode ~= "world" then return end
local alpha = mode == "world" and dim or 1
if not alpha or alpha <= 0 then return end
local G = love.graphics
local scale, ox, oy = panelBlit(self.stack, w, h)
local pw, ph = 160 * scale, 144 * scale
local stack = self.stack
if at and stack and stack.visibleBase then
local base = stack:visibleBase()
if base and base > at then owner = stack.states[base] end
end
local sw, sh = 160, 144
if owner and owner.panelSize then sw, sh = owner:panelSize() end
if sw ~= 160 then
if owner.battlePanelScale then
scale = owner:battlePanelScale(w, h) or scale
end
ox, oy = Chrome.fitOriginFor(w, h, scale, sw / 8, sh / 8)
end
local pw, ph = sw * scale, sh * scale
G.setColor(0, 0, 0, alpha)
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end
+1
View File
@@ -316,6 +316,7 @@ Save.DEFAULT_OPTIONS = {
palette = "",
videoMode = "windowed",
fpsCap = 60,
battleLayout = "og",
-- BATTLE SIZE (#1709): fixed | fill
battleFit = "fixed",
battleBg = "white",
+2 -2
View File
@@ -8,7 +8,7 @@ local GameVersion = require("src.core.GameVersion")
local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.FORMAT = "rom-cache-v11:"
CacheContract.VERSION_FORMAT = {
-- v11: Gen 2 maps carry their object list's ROM address, which a .sav
-- export re-anchoring a save onto another map writes back into
@@ -18,7 +18,7 @@ CacheContract.VERSION_FORMAT = {
silver = "rom-cache-v12:",
crystal = "rom-cache-v12-crystal4:",
-- engine/overworld/map_sprites.asm:181
yellow = "rom-cache-v10-yellow1:",
yellow = "rom-cache-v11-yellow1:",
}
CacheContract.MARKER_PATH = "rom-cache.complete"
+3
View File
@@ -681,6 +681,9 @@ local function gen2Rows(opts, hooks, shared)
end)
end
add(Strings("BATTLE LAYOUT"), ladder(opts, "battleLayout",
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
add(Strings("BATTLE SIZE"), ladder(opts, "battleFit",
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
+8 -1
View File
@@ -1452,7 +1452,8 @@ function RomExtractor:textGlyph(value)
if TEXT_GLYPH_OVERRIDES[value] then return TEXT_GLYPH_OVERRIDES[value] end
local glyph = self.manifest.charmap[tostring(value)]
or ("{BYTE:%02X}"):format(value)
if glyph:sub(1, 1) == "<" and glyph:sub(-1) == ">" then
-- home/text.asm:186
if glyph:match("^<[^<>]*>$") then
return "{" .. glyph:sub(2, -2) .. "}"
end
return glyph
@@ -1468,6 +1469,8 @@ function RomExtractor:decodeTextCommands(symbol, substitutions)
if command == 0x50 then
assert(pending > #substitutions,
symbol.name .. ": unused dynamic text substitutions")
-- home/text.asm:221
if #out > 0 then out[#out + 1] = "{DONE}" end
return table.concat(out)
elseif command == 0 then
while true do
@@ -1477,6 +1480,10 @@ function RomExtractor:decodeTextCommands(symbol, substitutions)
if value == 0x57 or value == 0x58 or value == 0x5F then
assert(pending > #substitutions,
symbol.name .. ": unused dynamic text substitutions")
-- constants/charmap.asm:19-20
if value ~= 0x5F and #out > 0 then
out[#out + 1] = (value == 0x58) and "{PROMPT}" or "{DONE}"
end
return table.concat(out)
end
out[#out + 1] = self:textGlyph(value)
+10 -2
View File
@@ -97,10 +97,13 @@ end
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322);
-- stones keep the menu on screen (engine/items/item_effects.asm:772-793,
-- engine/pokemon/evos_moves.asm:120-128)
-- engine/pokemon/evos_moves.asm:120-128
-- (engine/items/item_effects.asm:2022-2039)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
or VITAMINS[id] ~= nil or ItemEffects.isStone(id)
or id == "ELIXER" or id == "MAX_ELIXER"
or id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
end
function ItemEffects.isBattleMedicine(id)
@@ -327,6 +330,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
}
end
b.stages[stat] = cur + 1
-- effects.asm:414-415
require("src.battle.Status")
.afterStatChange(battle, b, stat, battle.enemy)
b.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" then
@@ -388,7 +394,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "failed", { noEffect(data) }
end
-- pokered's line names no mon, so the extracted text takes no args
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }
-- engine/items/item_effects.asm:2035
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") },
USE_JINGLE
end
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
+4 -3
View File
@@ -1856,7 +1856,7 @@ local function buildBattleState()
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "uiSize",
"drawClassic", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement",
@@ -1940,11 +1940,12 @@ COVERAGE["src.battle.BattleState"] = {
backed = "update draw __index isOpaque openParty swapMoves "
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
.. "tryRun wantsFillScale bgMode BG_WORLD_DIM",
.. "tryRun wantsFillScale bgMode BG_WORLD_DIM "
.. "isWideBattleLayout wideLayout",
warned = "tryRun askNicknameUI",
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout uiSize letterboxWhite "
.. "uiSize letterboxWhite "
.. "holdsUIAnchors trainerPalette trainerPicPath "
.. "trainerTrueColor trainerSprite invalidate "
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
-1
View File
@@ -15,7 +15,6 @@ local speciesMemo = {}
local FIELDS = {
{ key = "engine", text = "engine differs" },
{ key = "version", text = "game differs" },
{ key = "engineVersion", text = "engine version differs" },
{ key = "apiVersion", text = "mod api differs" },
{ key = "kind", text = "arena kind differs" },
+1
View File
@@ -40,6 +40,7 @@ GbcPalette.CUSTOM_MODE = "custom"
local DMG_SHADES = {
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
}
GbcPalette.DMG_SHADES = DMG_SHADES
-- #9BBC0F / #8BAC0F / #306230 / #0F380F, the same ramp Gen 1's CLASSIC uses.
local CLASSIC_SHADES = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
+25 -4
View File
@@ -76,6 +76,13 @@ function TextBox.ending(text)
return nil
end
-- pokered constants/charmap.asm:19-20
function TextBox.strip(text)
if type(text) ~= "string" then return text end
text = text:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", "")
return (text:gsub("{DONE}", ""):gsub("{PROMPT}", ""))
end
-- glyph offsets into the whole text -> [page][line][char]
local function mapPauses(pages, marks)
local at, acc, mi = {}, 0, 1
@@ -278,6 +285,7 @@ end
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
text = TextBox.strip(text)
-- maxCols is a column count, so the budget is that many vanilla 8px
-- cells. Measuring in pixels rather than columns is what lets a mod's
-- variable-advance page wrap correctly (#186).
@@ -373,12 +381,18 @@ function TextBox:sfxHeld()
return false
end
-- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (home/text.asm:749)
function TextBox:isGold()
local save = self.game and self.game.save
return not not (save and (save.generation == 2 or save.version == "gold"))
end
-- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (pokered home/text.asm:749)
function TextBox:arrowVisible()
if self.sfxWait then return false end
if self.waiting then return true end
-- ../pokecrystal/home/text.asm:566 DoneText
if self.waitButton then return false end
-- pokered home/text_script.asm:96 -> home/joypad2.asm:71-72
if self.waitButton and self:isGold() then return false end
return not not (self.done and not self.choice
and (not self.auto
or (self.auto.promptFirst and not self.autoPrompted))
@@ -386,6 +400,14 @@ function TextBox:arrowVisible()
or (self.stay.prompt and not self.stayShown)))
end
-- home/text.asm:209
-- ../pokecrystal/home/text.asm:631
function TextBox:arrowPos()
local gold = self:isGold()
return (self.boxTx + self.boxTw - 2) * 8,
gold and (self.boxTy + self.boxTh - 1) * 8 or self.line2Y
end
-- scripts/MtMoonPokecenter.asm:30
function TextBox:moneyVisible()
if not self.money then return false end
@@ -643,8 +665,7 @@ function TextBox:draw()
-- pokegold home/joypad.asm:430
local arrowOn = self.blink % 60 < 30
if gold then arrowOn = self.blink % 32 < 16 end
local arrowX = (self.boxTx + self.boxTw - 2) * 8
local arrowY = (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4)
local arrowX, arrowY = self:arrowPos()
if Chrome then
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
or Chrome.DEFAULT_BOX_PALETTE
+39
View File
@@ -21,6 +21,17 @@ local FLASH_FRAMES = 7
-- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them.
local FADE_STEP_FRAMES = 8
-- FadePal1..FadePal8, pokered home/fade.asm:65-73
local FADE_BGP = {
-- GBFadeOutToBlack / GBFadeInFromBlack, pokered home/fade.asm:43-46
black = { out = { 0xE4, 0xF9, 0xFE, 0xFF },
["in"] = { 0xFF, 0xFE, 0xF9, 0xE4 } },
-- GBFadeOutToWhite / GBFadeInFromWhite, pokered home/fade.asm:26-28
white = { out = { 0x90, 0x40, 0x00 },
["in"] = { 0x40, 0x90, 0xE4 } },
}
local BGP_IDENTITY = 0xE4
-- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack
-- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1
-- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid
@@ -131,7 +142,35 @@ function Transition:alpha()
return a
end
-- the $E4 identity BGP, pokered engine/gfx/palettes.asm:517-518
function Transition.shadeMapFor(byte)
if not byte or byte == BGP_IDENTITY then return nil end
local map = {}
for i = 0, 3 do map[i] = math.floor(byte / (4 ^ i)) % 4 end
return map
end
function Transition:fadeTable()
local c = self.color or { 0, 0, 0 }
local white = (c[1] or 0) > 0.5 and (c[2] or 0) > 0.5 and (c[3] or 0) > 0.5
local set = FADE_BGP[white and "white" or "black"]
return set[self.phase == "in" and "in" or "out"]
end
-- pokered home/fade.asm:58
function Transition:bgp()
local len = (self.phase == "out") and self.frames or self.framesIn
local tab = self:fadeTable()
local step = math.min(#tab,
math.floor(fadeAlpha(self.t, len) * (#tab - 1) + 0.5) + 1)
return tab[step] or 0xFF
end
function Transition:draw()
if self.paletteStepped then
self.paletteStepped = false
return
end
local alpha = self:alpha()
local c = self.color or { 0, 0, 0 }
-- Survey zoom draws the overworld into a window-filling world canvas
+34 -8
View File
@@ -299,9 +299,9 @@ end
-- and battle music.
function Commands.pushBattle(ctx, battle, keepNpc)
if ctx.overworld and ctx.overworld.pushBattle then
-- home/text_script.asm:8
-- engine/battle/battle_transitions.asm:14
ctx.overworld:pushBattle(battle, keepNpc or ctx.npc or nil)
-- hSpriteIndex -- pokered home/text_script.asm:2
-- pokered engine/battle/battle_transitions.asm:14
ctx.overworld:pushBattle(battle, keepNpc or ctx.npc or ctx.scriptSprite or nil)
else
Logger.warn("pushBattle: no overworld:pushBattle, skipping the transition wipe")
ctx.game.stack:push(battle)
@@ -439,10 +439,17 @@ local function bfsPath(map, sx, sy, tx, ty, entities, mover)
return nil
end
-- hSpriteIndex (pokered home/text_script.asm:2)
-- OAM block over the wipe (pokered engine/battle/battle_transitions.asm:14)
local function stampSpriteIndex(ctx, npc)
ctx.scriptSprite = npc
return npc
end
function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
local npc = stampSpriteIndex(ctx, ow:npcByIndex(objIndex))
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
if not path then
@@ -469,7 +476,8 @@ end
-- face an arbitrary map object (by object_event index)
function Commands.face_object(ctx, objIndex, dir)
local npc = ctx.overworld and ctx.overworld:npcByIndex(objIndex)
local npc = stampSpriteIndex(ctx,
ctx.overworld and ctx.overworld:npcByIndex(objIndex))
if npc then npc.facing = dir end
end
@@ -478,7 +486,7 @@ end
function Commands.place_npc(ctx, objIndex, x, y, facing)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
local npc = stampSpriteIndex(ctx, ow:npcByIndex(objIndex))
if not npc then return end
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
@@ -612,6 +620,23 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- wait_sound: WaitForSoundToFinish (home/delay.asm:15), the drain that
-- follows every PlaySound in the cell-separator chain
-- (engine/events/hidden_events/bills_house_pc.asm:22)
local WAIT_SOUND_CEILING = 600
function Commands.wait_sound(ctx)
local Sound = require("src.core.Sound")
if not Sound.sfxBusy() then return end
local runner = ctx.runner
local left = WAIT_SOUND_CEILING
runner.waitingCheck = function()
left = left - 1
return left <= 0 or not Sound.sfxBusy()
end
runner:yield()
end
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
@@ -1149,7 +1174,8 @@ end
function Commands.walk_npc(ctx, objIndex, dirs, opts)
local ow = ctx.overworld
if not ow then return end
local entity = objIndex == "player" and ow.player or ow:npcByIndex(objIndex)
local entity = objIndex == "player" and ow.player
or stampSpriteIndex(ctx, ow:npcByIndex(objIndex))
if not entity then return end
claimMove(ctx, entity)
local runner = ctx.runner
@@ -1467,7 +1493,7 @@ end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"give_pokemon", "wait", "wait_sound",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way",
"ss_anne_departs" }) do
+42 -2
View File
@@ -38,6 +38,17 @@ local TURN_HEAD = 0x00
local TURN_IN = 0x24
local STEP_DIG = 0x4f
local STEP_DIG_FRAMES = 16
-- macros/scripts/movement.asm:99-106
local SHOW_OBJECT = 0x3c
local HIDE_OBJECT = 0x3d
-- macros/scripts/movement.asm:211-214, engine/overworld/movement.asm:142-160
local RETURN_DIG = 0x58
-- engine/events/overworld.asm:864-872
local DIG_SPIN_FRAMES = 32
-- macros/scripts/movement.asm:158-160, engine/overworld/map_objects.asm:1368
local SKYFALL = 0x4e
local SKYFALL_TOP = 0x59
local SKYFALL_BEAT_FRAMES = 16
function Movement.dir(nibble)
return DIR[nibble % 4] or "down"
@@ -100,8 +111,10 @@ function Movement.decodeByte(b)
-- turn one either. Route 30's two Rattata lunge under it
-- (maps/Route30.asm:183-193) and ended up facing away from the fight.
return { kind = "fixfacing", fixed = (b == FIX_FACING) }
elseif b == 0x3c or b == 0x3d then -- show/hide object
return { kind = "nop" }
elseif b == SHOW_OBJECT or b == HIDE_OBJECT then
-- OBJECT_FLAGS1 (macros/scripts/movement.asm:99-106)
-- (engine/events/overworld.asm:864-872)
return { kind = "visible", on = (b == SHOW_OBJECT) }
elseif b >= 0x3e and b <= 0x46 then -- step_sleep N
return { kind = "sleep", frames = (b - 0x3e + 1) * 16 }
elseif b == TELEPORT_FROM or b == TELEPORT_TO then
@@ -130,6 +143,17 @@ function Movement.decodeByte(b)
-- pass and Sudowoodo's shake played in zero frames
-- (maps/Route36.asm:260-262 SudowoodoShakeMovement is this byte alone).
return { kind = "treeshake", frames = TREE_SHAKE_FRAMES }
elseif b == RETURN_DIG then
-- Movement_return_dig (engine/overworld/movement.asm:142-160)
-- (engine/overworld/map_objects.asm:1481-1493)
return { kind = "returndig" }
elseif b == SKYFALL or b == SKYFALL_TOP then
-- StepFunction_Skyfall (engine/overworld/map_objects.asm:1368)
return {
kind = (b == SKYFALL) and "skyfall" or "skyfalltop",
frames = (b == SKYFALL) and (2 * SKYFALL_BEAT_FRAMES)
or SKYFALL_BEAT_FRAMES,
}
end
return { kind = "nop" }
end
@@ -151,6 +175,13 @@ Movement.TREE_SHAKE = TREE_SHAKE
Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES
Movement.STEP_DIG = STEP_DIG
Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES
Movement.SHOW_OBJECT = SHOW_OBJECT
Movement.HIDE_OBJECT = HIDE_OBJECT
Movement.RETURN_DIG = RETURN_DIG
Movement.DIG_SPIN_FRAMES = DIG_SPIN_FRAMES
Movement.SKYFALL = SKYFALL
Movement.SKYFALL_TOP = SKYFALL_TOP
Movement.SKYFALL_BEAT_FRAMES = SKYFALL_BEAT_FRAMES
-- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE
-- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count
@@ -214,4 +245,13 @@ function Movement.forcedMovementBytes(back)
}
end
-- .DigOut / .DigReturn -- engine/events/overworld.asm:864-872
function Movement.digOutBytes()
return { STEP_DIG, DIG_SPIN_FRAMES, HIDE_OBJECT, STEP_END }
end
function Movement.digReturnBytes()
return { SHOW_OBJECT, RETURN_DIG, DIG_SPIN_FRAMES, STEP_END }
end
return Movement
+24 -18
View File
@@ -13,6 +13,9 @@ local BagMenu = {}
local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
-- (engine/menus/start_sub_menus.asm:414-416, home/list_menu.asm:56-82)
local BAG_RETURN_WHITE = 19
-- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game)
local items = {}
@@ -86,8 +89,14 @@ end
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
-- engine/menus/start_sub_menus.asm:410-416, home/list_menu.asm:56-82
-- engine/items/item_effects.asm:1244
local function closePicker()
if picker then picker:close() end
if not picker then return end
picker:close()
if battle then return end
local Transition = require("src.render.Transition")
game.stack:push(Transition.whiteFlash(game, BAG_RETURN_WHITE))
end
-- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open
@@ -376,8 +385,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- mashing A burns through a stack of them (#796)
-- RareCandyText carries sound_get_item_1
-- (engine/menus/party_menu.asm:289-293)
-- engine/items/item_effects.asm:1394
-- pokered engine/items/item_effects.asm:1392-1394
if picker and picker.eraseCursors then picker:eraseCursors() end
if picker and picker.setMessage then picker:setMessage(payload[1]) end
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
local statBox
@@ -489,7 +499,8 @@ local function pickTargetAndUse(game, battle, id, list)
-- the party menu (item_effects.asm:1392-1418); TM/HM stays up through
-- `predef LearnMove` (item_effects.asm:2238) (#1686)
-- engine/items/item_effects.asm:805 ItemUseMedicine, :1244 .done (#1946)
keepOpen = ItemEffects.healsHP(id)
-- engine/items/item_effects.asm:1959
keepOpen = ItemEffects.healsHP(id) or wantsMove
or ((not battle)
and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil))),
onSwitch = function(mon, picker)
@@ -497,21 +508,16 @@ local function pickTargetAndUse(game, battle, id, list)
useOn(game, battle, id, mon, list, nil, picker)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, Strings.source("Which move?"), rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
-- engine/items/item_effects.asm:1973
local prompt = (id == "PP_UP")
and romText(game.data, "_RaisePPWhichTechniqueText",
"Raise PP of which\ntechnique?")
or romText(game.data, "_RestorePPWhichTechniqueText",
"Restore PP of\nwhich technique?")
require("src.ui.Screens").push(game, "MoveSelectMenu", mon, prompt,
function(moveIndex)
useOn(game, battle, id, mon, list, moveIndex, picker)
end)
end,
}
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
+37 -3
View File
@@ -13,6 +13,23 @@ local Strings = require("src.core.Strings")
local BoxMenu = {}
-- engine/pokemon/bills_pc.asm:118
-- vChars2 $78; engine/menus/save.asm:497
local ballTile
local function drawBallTile(tx, ty)
if ballTile == nil then
local ok, img = pcall(love.graphics.newImage,
"assets/generated/battle/balls.png")
ballTile = ok and { img = img,
quad = love.graphics.newQuad(0, 0, 8, 8, img:getDimensions()) } or false
end
if not ballTile then return end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(ballTile.img, ballTile.quad, tx * 8, ty * 8)
love.graphics.setColor(r, g, b, a)
end
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
local function monRow(game, mon, value)
@@ -243,8 +260,10 @@ local function changeBoxMenu(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local y = 112
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
.. "\n"):gmatch("([^\n]*)\n") do
-- data/text/text_3.asm:30
local prompt = require("src.render.TextBox")
.strip(t._ChooseABoxText or Strings("Choose a\n<PK><MN> BOX."))
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, y)
y = y + 16
end
@@ -256,7 +275,7 @@ local function changeBoxMenu(game)
baseDraw(self)
love.graphics.setColor(0, 0, 0, 1)
for i = 1, Boxes.COUNT do
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
if #boxes[i] > 0 then drawBallTile(18, i) end
end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -354,6 +373,21 @@ function BoxMenu.new(game)
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
-- bills_pc.asm:176
for _, item in ipairs(items) do
local onSelect = item.onSelect
if item.keepOpen and onSelect then
item.onSelect = function()
menu.hollowIndex = menu.index
onSelect()
end
end
end
local baseUpdate = menu.update
function menu:update(dt)
if self.game.stack:top() == self then self.hollowIndex = nil end
return baseUpdate(self, dt)
end
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
+21 -7
View File
@@ -233,20 +233,36 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
local metric = e.heightM ~= nil
local numbers = owned and e.heightFt
-- engine/menus/pokedex.asm:449
if not metric then
Font.draw(Strings("HT"), 72, 48)
Font.draw("′", 112, 48)
Font.draw("″", 136, 48)
Font.draw(Strings("WT"), 72, 64)
Font.draw(Strings("lb"), 136, 64)
if not numbers then
Font.draw("?", 104, 48)
Font.draw("??", 120, 48)
Font.draw("???", 112, 64)
end
end
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
-- cry the line above it started
if state.crying then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
if e.heightM then
-- engine/menus/pokedex.asm:515-566
if numbers then
if metric then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
Font.draw(("%2d"):format(e.heightFt), 96, 48)
Font.draw(("%02d"):format(e.heightIn or 0), 120, 48)
Font.draw(("%6.1f"):format((e.weight or 0) / 10), 88, 64)
end
end
local pages = descPages(game, def, forceOwned)
@@ -260,8 +276,6 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta
if page < #pages and state.arrow ~= false then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
Font.draw(Strings("Data unknown."), 8, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
+4 -1
View File
@@ -158,7 +158,10 @@ function Menu:draw()
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
-- hollowIndex: home/window.asm:198
Font.drawCode(self.hollowIndex == self.index
and Theme.cursorHollow or Theme.cursor,
(self.tx + 1) * 8, rowY(self.index - self.scroll))
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
+61
View File
@@ -0,0 +1,61 @@
-- (engine/battle/core.asm:2520 .relearnmenu, engine/items/item_effects.asm:1971)
local Font = require("src.render.Font")
local MoveSelectMenu = {}
MoveSelectMenu.__index = MoveSelectMenu
local CURSOR = 0xED
function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel)
local self = setmetatable({}, MoveSelectMenu)
self.game = game
self.mon = mon
-- pokered engine/items/item_effects.asm:2136
self.prompt = require("src.render.TextBox").strip(prompt or "")
self.onChoose = onChoose
self.onCancel = onCancel
-- pokered engine/battle/core.asm:2542-2543
self.index = 1
return self
end
function MoveSelectMenu:update()
local input = self.game.input
local n = #self.mon.moves
-- engine/battle/core.asm:2692
if n > 0 and input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or n
elseif n > 0 and input:wasPressed("down") then
self.index = self.index < n and self.index + 1 or 1
elseif input:wasPressed("b") or (n < 1 and input:wasPressed("a")) then
-- engine/items/item_effects.asm:1985
self.game.stack:pop()
if self.onCancel then self.onCancel() end
elseif input:wasPressed("a") then
local picked = self.index
self.game.stack:pop()
if self.onChoose then self.onChoose(picked) end
end
end
function MoveSelectMenu:draw()
-- engine/battle/core.asm:2526
Font.drawBox(4, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1)
for i = 1, 4 do
local mv = self.mon.moves[i]
local mdef = mv and self.game.data.moves[mv.id]
-- engine/battle/misc.asm:37
Font.draw(mv and (mdef and mdef.name or mv.id) or "-", 48, (7 + i) * 8)
end
Font.drawCode(CURSOR, 40, (7 + self.index) * 8)
-- engine/items/item_effects.asm:1979
Font.drawBox(0, 12, 20, 6)
local first, rest = self.prompt:match("^(.-)\n(.*)$")
Font.draw(first or self.prompt, 8, 14 * 8)
if rest then Font.draw(rest, 8, 16 * 8) end
love.graphics.setColor(1, 1, 1, 1)
end
return MoveSelectMenu
+29 -7
View File
@@ -330,6 +330,8 @@ function PartyMenu.new(game, opts)
self.party = party -- link/scoped battles pass their local party view
self.swapFrom = nil
self.swapAnim = nil
-- wPartyMenuTypeOrMessageID (engine/menus/party_menu.asm:182)
self.message = nil
self.submenu = nil
self.subIndex = 1
self.blink = 0
@@ -402,6 +404,12 @@ function PartyMenu:update(dt)
local anim = self.swapAnim
if anim then
anim.frames = anim.frames + 1
-- engine/menus/start_sub_menus.asm:664-666
if anim.phase == 1 then
anim.blank[anim.to] = true
anim.phase = 2
return
end
local playing = false
if anim.src then
local ok, p = pcall(anim.src.isPlaying, anim.src)
@@ -687,7 +695,9 @@ function PartyMenu:update(dt)
if from ~= self.index then
party[from], party[self.index] = party[self.index], party[from]
end
self.swapAnim = { blank = { [from] = true }, frames = 0 }
-- wCurrentMenuItem (engine/menus/start_sub_menus.asm:662-666)
self.swapAnim = { blank = { [from] = true }, to = self.index,
phase = 1, frames = 0 }
if self.game.data then
self.swapAnim.src = require("src.core.Sound").play(self.game.data, "Swap")
end
@@ -776,23 +786,35 @@ function PartyMenu:update(dt)
end
-- engine/menus/party_menu.asm:229 (#147 #1610 #1901)
-- engine/menus/party_menu.asm:226-235
function PartyMenu:setMessage(text)
if type(text) ~= "string" then self.message = nil return end
-- constants/charmap.asm:19-20
self.message = require("src.render.TextBox").strip(text)
end
function PartyMenu:bottomMessage()
if self.swapFrom then
return self.game.data.text._PartyMenuSwapMonText
local text
if self.message then
text = self.message
elseif self.swapFrom then
text = self.game.data.text._PartyMenuSwapMonText
or Strings("Move POKéMON\nwhere?")
elseif self.tmhm then
return self.game.data.text._PartyMenuUseTMText
text = self.game.data.text._PartyMenuUseTMText
or Strings("Use TM on which\nPOKéMON?")
elseif self.softboiledFrom or self.itemUse then
return self.game.data.text._PartyMenuItemUseText
text = self.game.data.text._PartyMenuItemUseText
or Strings("Use item on which\nPOKéMON?")
elseif self.forceSwitch then
return self.game.data.text._PartyMenuBattleText
text = self.game.data.text._PartyMenuBattleText
or Strings("Bring out which\nPOKéMON?")
else
return self.game.data.text._PartyMenuNormalText
text = self.game.data.text._PartyMenuNormalText
or Strings("Choose a POKéMON.")
end
-- constants/charmap.asm:19-20
return require("src.render.TextBox").strip(text)
end
-- Name-row pixel Y for party slot i (1-based).
+3 -3
View File
@@ -23,16 +23,16 @@ local function txt(game, key, fallback)
return game.data.text[key] or fallback
end
-- engine/events/pokemart.asm:204 (.returnToMainPokemartMenu)
-- pokered engine/events/pokemart.asm:204
local function anythingElse(game)
return txt(game, "_PokemartAnythingElseText",
Strings("Is there anything\nelse I can do?"))
end
-- prompt refusals leave the list for the mart menu -- pokemart.asm:113
-- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206
local function refuse(game, menu, list, text)
if list then list:close() end
game.stack:push(TextBox.new(game, text, function()
if list then list:close() end
menu.footer = anythingElse(game)
end))
end
+4 -2
View File
@@ -358,8 +358,10 @@ function SlotMachine:resolveWin(win)
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7")
self.message = sym .. romText(self.game.data, "_LinedUpText",
" lined up!\nScored %d coins!", pay)
-- constants/charmap.asm:19-20
self.message = sym .. require("src.render.TextBox").strip(
romText(self.game.data, "_LinedUpText",
" lined up!\nScored %d coins!", pay))
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash"
+3 -2
View File
@@ -128,8 +128,9 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror)
G.draw(image, self:quad(image, index), tx * 8, ty * 8)
end
end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
-- home/fade.asm:35 (RotateThreePalettesRight)
if GbcPalette.available() then
GbcPalette.with(colors or GbcPalette.DMG_SHADES, body)
else
G.setColor(0, 0, 0, 1)
body()
+151 -59
View File
@@ -94,6 +94,10 @@ local TEXT_WIDTH = 18
local TEXT_ROWS = 2
local TEXT_ROW_STEP = 2
-- ../pokecrystal/home/text.asm:630
local DOWN_ARROW = "\xe2\x96\xbc"
local ARROW_X, ARROW_Y = 18, 17
-- ../pokecrystal/engine/battle/core.asm:9119-9137
-- ../pokecrystal/data/text/battle.asm:10-35
function BattleState.battleStartText(name, battleType)
@@ -282,8 +286,32 @@ function BattleState:wantsFillScale()
return (options and options.battleFit) == "fill"
end
local function isWide(self)
local options = self.game and self.game.options
return (options and options.battleLayout) == "wide"
end
BattleState.isWideBattleLayout = isWide
BattleState.wideLayout = isWide
function BattleState:panelSize()
if isWide(self) then
local WideBattle = require("src.ui.gen2.WideBattle")
return WideBattle.WIDTH, WideBattle.HEIGHT
end
return Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8
end
function BattleState:battlePanelScale(winW, winH)
return BattleState.panelScale(winW, winH, self:wantsFillScale())
if isWide(self) then
local WideBattle = require("src.ui.gen2.WideBattle")
if BattleState.wantsFillScale(self) then
return WideBattle.fillScale(winW, winH)
end
return Chrome.fitScaleFor(winW, winH, WideBattle.WIDTH / 8,
WideBattle.HEIGHT / 8)
end
return BattleState.panelScale(winW, winH, BattleState.wantsFillScale(self))
end
function BattleState:drawsWidescreen() return true end
@@ -351,6 +379,8 @@ function BattleState.new(game, opts)
self.queue = {}
self.message = nil
self.messageTimer = 0
-- ../pokecrystal/home/joypad.asm:428
self.arrowBlink = 0
self.phase = "intro" -- intro | menu | moves | resolving | evolving | done
-- wEvolvableFlags (ram/wram.asm, one bit per party slot).
-- engine/battle/core.asm sets a mon's bit the moment it levels up, right
@@ -510,6 +540,9 @@ function BattleState.new(game, opts)
-- announced. Nothing to slide when the cache has no trainer pic.
if self.showEnemyTrainer then
self:push({ kind = "trainer-slide" })
else
-- ../pokecrystal/engine/battle/core.asm:3549
self.picHidden.enemy = true
end
-- ShowBattleTextEnemySentOut, then ShowSetEnemyMonAndSendOutAnimation
-- (core.asm:2978-2980, 3354): this is where the mon's frontpic first
@@ -2546,6 +2579,8 @@ function BattleState:update(_dt)
if self.phase == "fadeout" then return self:stepExitFade() end
self:updateAlarm()
self:stepFrontAnim()
-- ../pokecrystal/home/joypad.asm:428
self.arrowBlink = ((self.arrowBlink or 0) + 1) % 32
local input = self.game and self.game.input
if not input then return end
@@ -2574,6 +2609,8 @@ function BattleState:update(_dt)
if self.trainerSlide >= TRAINER_SLIDE_FRAMES then
self.trainerSlide = nil
self.showEnemyTrainer = false
-- ../pokecrystal/engine/battle/core.asm:3221
self.picHidden.enemy = true
self:advanceQueue()
end
return
@@ -2884,10 +2921,7 @@ function BattleState:update(_dt)
return
end
-- CheckIfCurPartyMonIsFitToFight said no. Its text is read with the list
-- CLOSED here rather than over it (the party menu is its own screen in this
-- port), and the list comes back the moment the line is done -- which is
-- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry.
-- stack the refusal prints over the list itself (core.asm:2852-2857)
if self.phase == "refuse-switch" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
@@ -3009,7 +3043,8 @@ function BattleState:openParty(forced)
local stack = self.game and self.game.stack
if not stack then return false end
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
local list
list = Screens.push(self.game, "Gen2PartyMenu", {
-- PARTYMENUACTION_CHOOSE_POKEMON for the voluntary list and
-- PARTYMENUACTION_SWITCH for the forced one (engine/battle/core.asm:4795,
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
@@ -3025,31 +3060,33 @@ function BattleState:openParty(forced)
self.phase = forced and "forced-switch" or "menu"
end,
onChoose = function(index, mon)
stack:pop()
-- TryPlayerSwitch's own order, every arm ending on
-- `jp BattleMenuPKMN_Loop` (engine/battle/core.asm:4863-4888).
local refused, key, arg
if not forced and mon == self.battle.player then
return self:refuseSwitch(false, TEXT_ALREADY_OUT, self:name(mon))
end
if not forced and self.battle:switchLocked() then
return self:refuseSwitch(false, TEXT_CANT_BE_RECALLED,
self:name(self.battle.player))
end
-- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456).
if mon.isEgg then
return self:refuseSwitch(forced, TEXT_EGG_CANT_BATTLE)
end
if (mon.hp or 0) <= 0 then
refused, key, arg = true, TEXT_ALREADY_OUT, self:name(mon)
elseif not forced and self.battle:switchLocked() then
refused, key, arg = true, TEXT_CANT_BE_RECALLED,
self:name(self.battle.player)
elseif mon.isEgg then
-- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456)
refused, key = true, TEXT_EGG_CANT_BATTLE
elseif (mon.hp or 0) <= 0 then
-- CheckIfCurPartyMonIsFitToFight (engine/pokemon/party_menu.asm):
-- a fainted pick prints Text_TheresNoWillToFight and returns carry, so
-- the caller re-opens the list -- BattleMenu_PKMN's own loop for a
-- voluntary switch, ForcePickPartyMonInBattle's `jr c` after a faint.
-- Silently dropping the player back on the list (or, worse, back on
-- the battle menu) is what made the forced switch read as taking two
-- or three presses: the cursor opens on the mon that just fainted, and
-- pressing A on it did nothing a player could see.
return self:refuseSwitch(forced)
refused = true
end
-- Only the accepted switch tears the list down (core.asm:5186-5192)
if refused then
if list then
return list:refuse(Strings(key or TEXT_NO_WILL_TO_FIGHT, arg))
end
stack:pop()
return self:refuseSwitch(forced, key, arg)
end
stack:pop()
if forced then
-- ForcePickPartyMonInBattle loops on carry: a pick the engine will not
-- take has to come back as the list again, never as the battle menu
@@ -3528,7 +3565,8 @@ function BattleState:openShiftParty()
return self:advanceQueue()
end
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
local list
list = Screens.push(self.game, "Gen2PartyMenu", {
prompt = "which",
battle = true,
onCancel = function()
@@ -3537,12 +3575,23 @@ function BattleState:openShiftParty()
self:advanceQueue()
end,
onChoose = function(index, mon)
stack:pop()
-- SwitchMonAlreadyOut (core.asm:2864)
local refused, key, arg
if mon == self.battle.player then
return self:refuseShift(TEXT_ALREADY_OUT, self:name(mon))
refused, key, arg = true, TEXT_ALREADY_OUT, self:name(mon)
elseif mon.isEgg then
refused, key = true, TEXT_EGG_CANT_BATTLE
elseif (mon.hp or 0) <= 0 then
refused = true
end
if mon.isEgg then return self:refuseShift(TEXT_EGG_CANT_BATTLE) end
if (mon.hp or 0) <= 0 then return self:refuseShift(nil) end
if refused then
if list then
return list:refuse(Strings(key or TEXT_NO_WILL_TO_FIGHT, arg))
end
stack:pop()
return self:refuseShift(key, arg)
end
stack:pop()
self.shiftSwitchIndex = index
self.phase = "resolving"
self:advanceQueue()
@@ -4091,7 +4140,9 @@ end
-- above it, not inside a border.
function BattleState:drawFrame(tx, ty, width, stubRight)
local G = love.graphics
G.setColor(0, 0, 0, 1)
-- home/fade.asm:35 (RotateThreePalettesRight)
local ink = GbcPalette.color(GbcPalette.DMG_SHADES, 4) or { 0, 0, 0 }
G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
-- The bottom rule ($76 repeated, capped by $74/$78 or $6f/$77) sits at the
-- top of its own tile row, immediately under the bar above it.
G.rectangle("fill", tx * 8, ty * 8, width * 8, 2)
@@ -4099,6 +4150,7 @@ function BattleState:drawFrame(tx, ty, width, stubRight)
-- climbs from the rule past the bar row.
local stubX = stubRight and ((tx + width - 2) * 8 + 6) or (tx * 8)
G.rectangle("fill", stubX, ty * 8 - 8, 2, 10)
G.setColor(1, 1, 1, 1)
end
-- LoadBattleFontsHPBar puts FontBattleExtra in the $60 slot for the whole
@@ -4117,7 +4169,20 @@ end
function BattleState:drawHud()
local wasBattle = Font.useBattleExtra(true)
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
self:drawEnemyHud()
self:drawPics()
self:drawPlayerHud()
Font.useBattleExtra(wasBattle)
end
function BattleState:drawPics()
self:drawPic(self:activeMon("enemy"), false)
self:drawPic(self:activeMon("player"), true)
end
function BattleState:drawEnemyHud()
local wasBattle = Font.useBattleExtra(true)
local enemy = self:activeMon("enemy")
local showStatus = self:statusHUDVisible()
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
@@ -4164,14 +4229,13 @@ function BattleState:drawHud()
end
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
end
Font.useBattleExtra(wasBattle)
end
self:drawPic(enemy, false)
-- Player HUD (DrawPlayerHUD clears (9,7) 5 rows x 11 cols):
-- name at (10,7); PrintLevel at (14,8) with the gender at (17,8);
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
-- border from (18,10) going left, and the exp bar at (10,11).
self:drawPic(player, true)
function BattleState:drawPlayerHud()
local wasBattle = Font.useBattleExtra(true)
local player = self:activeMon("player")
local showStatus = self:statusHUDVisible()
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
if showStatus and self.ballRows.player then
@@ -4301,13 +4365,27 @@ end
-- The message on the cart's own two rows: 14 and 16, with 15 blank between
-- them (home/text.asm:143 and :397). A string that will not fit two 18-tile
-- lines is cut rather than spilling onto the rows Paragraph clears.
function BattleState:printMessage()
function BattleState:printMessage(ox)
ox = ox or 0
self:syncTyper()
local lines = self:messageLines()
for i = 1, math.min(#lines, TEXT_ROWS) do
Chrome.printThrough(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
end
if self:messageArrowVisible() then
Chrome.printThrough(DOWN_ARROW, ARROW_X + ox, ARROW_Y,
Chrome.DEFAULT_BOX_PALETTE)
end
end
-- ../pokecrystal/home/text.asm:548
-- ../pokecrystal/home/joypad.asm:428
function BattleState:messageArrowVisible()
if (self.messageTimer or 0) <= 0 then return false end
if self.waitSfx or (self.messageDelay or 0) > 0 then return false end
if Typer.typing(self) then return false end
return Typer.arrowOn(self)
end
-- ../pokecrystal/home/text.asm:660 StdBattleTextbox -> PrintText
@@ -4363,18 +4441,26 @@ function BattleState:drawMoveInfoBox(move)
5, 11, Chrome.DEFAULT_BOX_PALETTE)
end
local function hasBattleSides(self)
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
return not not (self.battle and hasPlayer and self.battle.enemy)
end
BattleState.hasBattleSides = hasBattleSides
function BattleState:drawPanel()
Chrome.clear()
-- A tutorial battle legitimately has no player mon, so only the enemy is
-- required there; everywhere else a missing side is a caller bug.
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
if not (self.battle and hasPlayer and self.battle.enemy) then
if not hasBattleSides(self) then
Chrome.printThrough(Strings("NO BATTLE"), 1, 1,
Chrome.DEFAULT_BOX_PALETTE)
return
end
self:drawHud()
self:drawBottom(0)
end
function BattleState:drawBottom(ox)
ox = ox or 0
if not self:bottomUIVisible() then
love.graphics.setColor(1, 1, 1, 1)
return
@@ -4391,18 +4477,18 @@ function BattleState:drawPanel()
local moveMenu = self.phase == "moves"
local forgetting = self.phase == "choose-forget"
and (self.messageTimer or 0) <= 0
Chrome.box(0, 12, 20, 6)
Chrome.box(0, 12, 20 + ox, 6)
if moveMenu then
-- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157).
Chrome.box(4, 12, 16, 6)
Chrome.box(4, 12, 16 + ox, 6)
Chrome.box(0, 8, 11, 5)
end
if self.phase == "menu" then
self:printMessage()
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
self:printMessage(ox)
local boxX = (self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X) + ox
local spacing = self.contest and CONTEST_MENU_COL_SPACING
or MENU_COL_SPACING
Chrome.box(boxX, 12, 20 - boxX, 6)
Chrome.box(boxX, 12, 20 + ox - boxX, 6)
for i, label in ipairs(self:menuLabels()) do
local col = ((i - 1) % 2) * spacing
local row = math.floor((i - 1) / 2) * 2
@@ -4414,7 +4500,7 @@ function BattleState:drawPanel()
end
elseif forgetting then
-- engine/pokemon/learn.asm:136-146
self:printMessage()
self:printMessage(ox)
local learn = self.pendingLearn
local mon = learn and self.battle.party[learn.index]
local moves = (mon and mon.moves) or self:playerMoves()
@@ -4447,7 +4533,7 @@ function BattleState:drawPanel()
self:drawMoveInfoBox(moves[cursorRow])
else
-- Battle messages wrap inside the box rather than running off the frame.
self:printMessage()
self:printMessage(ox)
-- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7`, a 6x5 box at (14,7) with YES at
-- (16,8) and NO at (16,10), drawn over the battle while the question
-- stands.
@@ -4458,7 +4544,7 @@ function BattleState:drawPanel()
-- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at
-- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410).
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
and 1 or 14
and 1 or (14 + ox)
Chrome.box(left, 7, 6, 5)
Chrome.printThrough(Strings("YES"), left + 2, 8,
Chrome.DEFAULT_BOX_PALETTE)
@@ -4474,7 +4560,7 @@ function BattleState:drawPanel()
end
GbcPalette.setBgp(previousBgp)
if self.phase == "stats-box" and self.statsBoxMon then
self:drawStatsBox(self.statsBoxMon)
self:drawStatsBox(self.statsBoxMon, math.floor(ox / 2))
end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -4487,7 +4573,8 @@ local STATS_BOX_ROWS = {
}
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
function BattleState:drawStatsBox(mon)
function BattleState:drawStatsBox(mon, ox)
ox = ox or 0
if not mon then return end
local stats = mon.stats
local data = self.game and self.game.data
@@ -4496,11 +4583,12 @@ function BattleState:drawStatsBox(mon)
stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
end
if not stats then return end
Chrome.textbox(9, 0, 9, 10)
Chrome.textbox(9 + ox, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do
local ty = 1 + (i - 1) * 2
Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1,
Chrome.printThrough(Strings(row[1]), 11 + ox, ty,
Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19 + ox, ty + 1,
Chrome.DEFAULT_BOX_PALETTE)
end
end
@@ -4508,9 +4596,9 @@ end
-- The BG layer, plus whatever the animation is doing to it, plus the OBJ
-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn
-- after the scanline blit rather than into the canvas with everything else.
function BattleState:drawScene()
function BattleState:drawScene(bodyFn)
local previousBgp = GbcPalette.setBgp(self:exitFadeBgp() or GbcPalette.bgp)
self:drawSceneBody()
if bodyFn then bodyFn() else self:drawSceneBody() end
GbcPalette.setBgp(previousBgp)
-- battle.overlay: shiny sparkles, custom HUD chrome, and so on. Draw-only,
-- and the same name, the same payload (the battle screen) and the same place
@@ -4560,8 +4648,8 @@ function BattleState:drawLiftedRows()
G.draw(self.liftCanvas, 0, 0)
end
function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end
function BattleState:drawSceneBody(panelFn)
local panel = panelFn or function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
-- The back pic is lifted out of the sliding bands and drawn the way the
-- cart's OAM copy is: one intact piece riding in from the right, so it
@@ -4593,10 +4681,14 @@ function BattleState:drawSceneBody()
end
function BattleState:draw()
if self:wideLayout() then return end
Chrome.withClip(function() self:drawScene() end)
end
function BattleState:drawWidescreen(winW, winH)
if self:wideLayout() then
return require("src.ui.gen2.WideBattle").draw(self, winW, winH)
end
Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawScene() end,
self:battlePanelScale(winW, winH))
end
+18 -7
View File
@@ -83,19 +83,28 @@ local function playfieldRect(winW, winH)
return 0, 0, winW or 0, winH or 0
end
function Chrome.fitScale(winW, winH)
function Chrome.fitScaleFor(winW, winH, tilesW, tilesH)
local _, _, w, h = playfieldRect(winW, winH)
return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8),
h / (Chrome.SCREEN_H * 8))))
return math.max(1, math.floor(math.min(w / (tilesW * 8), h / (tilesH * 8))))
end
function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH)
scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH)
local x, y, w, h = playfieldRect(winW, winH)
return x + math.floor((w - tilesW * 8 * scale) / 2),
y + math.floor((h - tilesH * 8 * scale) / 2)
- Chrome.positionLift(winW, winH, scale)
end
function Chrome.fitScale(winW, winH)
return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H)
end
-- The centred origin that goes with it, so a caller does not re-derive it.
function Chrome.fitOrigin(winW, winH, scale)
scale = scale or Chrome.fitScale(winW, winH)
local x, y, w, h = playfieldRect(winW, winH)
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
- Chrome.positionLift(winW, winH, scale)
return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W,
Chrome.SCREEN_H)
end
function Chrome.positionLift(winW, winH, scale)
@@ -113,6 +122,8 @@ local function clipTo(x, y, w, h)
else G.setScissor(x, y, w, h) end
end
Chrome.clipTo = clipTo
function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale)
local G = love.graphics
Chrome.letterbox(winW, winH, r, g, b)
+29 -12
View File
@@ -3,10 +3,12 @@
local Chrome = require("src.ui.gen2.Chrome")
local CommonText = require("src.core.gen2.CommonText")
local IntroFade = require("src.ui.gen2.IntroFade")
local Music = require("src.core.Music")
local RomText = require("src.core.RomText")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Typer = require("src.ui.gen2.Typer")
local GenderSelect = {}
GenderSelect.__index = GenderSelect
@@ -57,8 +59,12 @@ function GenderSelect.new(game, opts)
-- `db 1 ; default option`: the cursor opens on Boy.
self.cursor = 1
self.exit = nil
self.fades = opts.fades and true or false
self.text = CommonText.plain(RomText(self.data,
"_AreYouABoyOrAreYouAGirlText", FALLBACK))
-- ../pokecrystal/home/print_text.asm:5, ../pokecrystal/engine/menus/init_gender.asm:30-34
self.typer = Typer.new(game, {})
self.typer:start(self.text)
return self
end
@@ -82,12 +88,24 @@ function GenderSelect:choose(index)
self.exit = EXIT_FRAMES
end
function GenderSelect:leave()
local function done()
if self.onDone then self.onDone(self.chosen) end
end
-- ../pokecrystal/engine/rtc/timeset.asm:22
if self.fades then return IntroFade.run(self, { "outBlack" }, done) end
done()
end
function GenderSelect:update(_dt)
-- ../pokecrystal/home/fade.asm:22-101
if IntroFade.advance(self) then return end
if self.typer then self.typer:tick() end
if self.exit then
self.exit = self.exit - 1
if self.exit > 0 then return end
self.exit = nil
if self.onDone then self.onDone(self.chosen) end
self:leave()
return
end
local input = self.game and self.game.input
@@ -112,7 +130,8 @@ function GenderSelect:drawPanel()
G.setColor(1, 1, 1, 1)
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
-- ../pokecrystal/home/text.asm:473
Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2)
Chrome.printWrapped(table.concat(Typer.text(self, {}), "\n"),
SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2)
Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H)
for i, option in ipairs(GenderSelect.OPTIONS) do
local row = TEXT_Y + (i - 1) * ROW_STEP
@@ -121,20 +140,18 @@ function GenderSelect:drawPanel()
end
end
function GenderSelect:draw()
function GenderSelect:drawBody()
self:drawPanel()
IntroFade.paint(self, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
end
function GenderSelect:draw()
Chrome.withClip(function() self:drawBody() end)
end
function GenderSelect:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawPanel()
G.pop()
local r, g, b = IntroFade.surround(self, 1, 1, 1)
Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end)
end
return GenderSelect
+76 -16
View File
@@ -25,7 +25,9 @@
local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock")
local IntroFade = require("src.ui.gen2.IntroFade")
local Strings = require("src.core.Strings")
local Typer = require("src.ui.gen2.Typer")
local InitClock = {}
InitClock.__index = InitClock
@@ -60,6 +62,9 @@ InitClock.TEXT = TEXT
-- same three and has always had them right.
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
-- ../pokecrystal/engine/rtc/timeset.asm:24
InitClock.GROUND = { 0, 0, 0 }
-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
-- table: MainMenu's clock box and the Pokegear's clock card read the same
-- weekday off the same save and must never disagree about what it is
@@ -142,9 +147,32 @@ function InitClock.new(game, opts)
-- (a \f here) on a button press like any other text box, and Oak's opening
-- is three lines long over two of them.
self.page = 1
-- ../pokecrystal/home/print_text.asm:5
self.typer = Typer.new(game, {
instant = self.autoConfirm or self.mode == "day",
})
self:startText()
self.fades = opts.fades and self.mode ~= "day" and not self.autoConfirm
if self.fades then
-- ../pokecrystal/engine/rtc/timeset.asm:22, :42
if opts.faded then
IntroFade.run(self, { "inBlack" })
else
-- ../pokecrystal/engine/menus/intro_menu.asm:42-49, :65
self.blank = true
IntroFade.run(self, { "outBlack" }, function()
self.blank = false
IntroFade.run(self, { "inBlack" })
end)
end
end
return self
end
function InitClock:startText()
if self.typer then self.typer:start(self:pageText()) end
end
-- The current question, split into its pages.
function InitClock:pages()
local question = self:question()
@@ -240,7 +268,12 @@ function InitClock:finish()
return
end
Clock.setTime(self.save, self.hour, self.minute)
if self.onDone then self.onDone(self.hour, self.minute) end
local function done()
if self.onDone then self.onDone(self.hour, self.minute) end
end
-- ../pokecrystal/engine/menus/intro_menu.asm:628
if self.fades then return IntroFade.run(self, { "outBlack" }, done) end
done()
end
-- A on a picker confirms it, YES on a confirmation takes it, NO drops back to
@@ -249,6 +282,7 @@ function InitClock:accept()
-- A on a page that has more behind it turns the page, the way `para` does.
if self:morePages() then
self.page = self.page + 1
self:startText()
return
end
self.page = 1
@@ -263,12 +297,14 @@ function InitClock:accept()
elseif self.phase == "confirm-minute" then
self.phase = "response"
elseif self.phase == "response" then
self:finish()
-- ../pokecrystal/engine/menus/intro_menu.asm:628
return self:finish()
elseif self.phase == "day" then
self.phase = "confirm-day"
elseif self.phase == "confirm-day" then
self:finish()
return self:finish()
end
self:startText()
end
function InitClock:decline()
@@ -279,9 +315,12 @@ function InitClock:decline()
elseif self.phase == "confirm-day" then
self.phase = "day"
end
self:startText()
end
function InitClock:update(_dt)
-- ../pokecrystal/home/fade.asm:22-101
if IntroFade.advance(self) then return end
-- The driver path: no screen this new may be allowed to stall a scripted
-- run, so it walks itself to the end taking every default.
if self.autoConfirm then
@@ -289,6 +328,13 @@ function InitClock:update(_dt)
return
end
local input = self.game and self.game.input
-- ../pokecrystal/home/print_text.asm:5, ../pokecrystal/home/text.asm:473
if self.typer and not self.typer:tick() then
if input and (input:wasPressed("a") or input:wasPressed("b")) then
self.typer.shown = self.typer.total
end
return
end
if not input then return end
if self:confirming() and not self:morePages() then
-- YesNoBox: the cursor walks two rows and B is NO.
@@ -345,8 +391,21 @@ local function arrow(tx, ty, up)
end
function InitClock:drawPanel()
-- ../pokecrystal/engine/rtc/timeset.asm:22
if self.mode ~= "day" then Chrome.clear() end
-- ../pokecrystal/engine/menus/intro_menu.asm:42-49
if self.blank then
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
return
end
-- ../pokecrystal/engine/rtc/timeset.asm:22-32
if self.mode ~= "day" then
local G = love.graphics
local ground = InitClock.GROUND
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
G.setColor(1, 1, 1, 1)
end
local value = self:display()
if value then
local bx, by, bw, bh, arrowX, tx, ty = self:pickerBox()
@@ -361,7 +420,7 @@ function InitClock:drawPanel()
-- Gold prompt uses.
Chrome.textbox(0, 12, 18, 4)
-- ../pokecrystal/home/text.asm:473
Chrome.printWrapped(self:pageText(), 1, 14, 18, 2, 2)
Chrome.printWrapped(table.concat(Typer.text(self, {}), "\n"), 1, 14, 18, 2, 2)
if self:confirming() then
-- ../pokecrystal/home/menu.asm:418
Chrome.box(14, 7, 6, 5)
@@ -371,20 +430,21 @@ function InitClock:drawPanel()
end
end
function InitClock:draw()
function InitClock:drawBody()
self:drawPanel()
IntroFade.paint(self, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
end
function InitClock:draw()
Chrome.withClip(function() self:drawBody() end)
end
function InitClock:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawPanel()
G.pop()
local ground = InitClock.GROUND
local r, g, b = ground[1] / 255, ground[2] / 255, ground[3] / 255
if self.blank then r, g, b = 1, 1, 1 end
r, g, b = IntroFade.surround(self, r, g, b)
Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end)
end
return InitClock
+117
View File
@@ -0,0 +1,117 @@
-- ../pokecrystal/home/fade.asm:22-101 RotateFourPalettes* / RotateThreePalettes*
-- (src/ui/gen2/MenuFade.lua is the other ramp, home/map.asm:1910-1940.)
local IntroFade = {}
IntroFade.__index = IntroFade
-- ../pokecrystal/home/fade.asm:56-57, :99-100
local STEP_FRAMES = 8
IntroFade.STEP_FRAMES = STEP_FRAMES
-- ../pokecrystal/home/fade.asm:106-113
-- outBlack RotateFourPalettesLeft 03 02 01 00
-- inBlack RotateFourPalettesRight 00 01 02 03
-- outWhite RotateThreePalettesRight 05 06 07
-- inWhite RotateThreePalettesLeft 06 05 04
local LEVELS = {
outBlack = { 0, 1 / 3, 2 / 3, 1 },
inBlack = { 1, 2 / 3, 1 / 3, 0 },
outWhite = { 1 / 3, 2 / 3, 1 },
inWhite = { 2 / 3, 1 / 3, 0 },
}
local BLACK, WHITE = { 0, 0, 0 }, { 1, 1, 1 }
local COLORS = {
outBlack = BLACK, inBlack = BLACK, outWhite = WHITE, inWhite = WHITE,
}
IntroFade.FOUR_FRAMES = 4 * STEP_FRAMES
IntroFade.THREE_FRAMES = 3 * STEP_FRAMES
function IntroFade.frames(kind)
local levels = LEVELS[kind]
return levels and #levels * STEP_FRAMES or 0
end
function IntroFade.new(kind)
local levels = LEVELS[kind]
if not levels then return nil end
return setmetatable({
kind = kind,
levels = levels,
color = COLORS[kind],
frame = 0,
total = #levels * STEP_FRAMES,
}, IntroFade)
end
function IntroFade:done() return self.frame >= self.total end
function IntroFade:tick()
if self.frame < self.total then self.frame = self.frame + 1 end
return self:done()
end
function IntroFade:level()
local k = math.floor(self.frame / STEP_FRAMES) + 1
if k > #self.levels then k = #self.levels end
return self.levels[k]
end
function IntroFade:draw(w, h)
local a = self:level()
if a <= 0 then return end
local G = love.graphics
local c = self.color
G.setColor(c[1], c[2], c[3], a)
G.rectangle("fill", 0, 0, w, h)
G.setColor(1, 1, 1, 1)
end
function IntroFade:blend(r, g, b)
local a = self:level()
local c = self.color
return r + (c[1] - r) * a, g + (c[2] - g) * a, b + (c[3] - b) * a
end
-- ---------------------------------------------------------- owner-side mixin
function IntroFade.step(owner)
owner.fadeAt = (owner.fadeAt or 0) + 1
local kind = owner.fadeQueue and owner.fadeQueue[owner.fadeAt]
if not kind then
owner.fade, owner.fadeQueue, owner.fadeAt = nil, nil, nil
local fn = owner.fadeDone
owner.fadeDone = nil
if fn then fn() end
return
end
owner.fade = IntroFade.new(kind)
end
function IntroFade.run(owner, kinds, onDone)
owner.fadeQueue, owner.fadeAt, owner.fadeDone = kinds, 0, onDone
IntroFade.step(owner)
end
function IntroFade.busy(owner)
return owner.fade ~= nil
end
-- True when the fade owned this frame, so the caller returns before its input.
function IntroFade.advance(owner)
if not owner.fade then return false end
if owner.fade:tick() then IntroFade.step(owner) end
return true
end
function IntroFade.paint(owner, w, h)
if owner.fade then owner.fade:draw(w, h) end
end
function IntroFade.surround(owner, r, g, b)
if not owner.fade then return r, g, b end
return owner.fade:blend(r, g, b)
end
return IntroFade
+95 -25
View File
@@ -42,6 +42,7 @@ local FieldMoves = require("src.world.gen2.FieldMoves")
local Font = require("src.render.Font")
local Gen2Save = require("src.core.gen2.Save")
local GbcPalette = require("src.render.GbcPalette")
local IntroFade = require("src.ui.gen2.IntroFade")
local Logger = require("src.core.Logger")
local Music = require("src.core.Music")
local Palettes = require("src.world.gen2.Palettes")
@@ -58,6 +59,21 @@ OakSpeech.isOpaque = true
local FADE_FRAMES = 24
local WIPE_FRAMES = 32
-- ../pokecrystal/engine/menus/intro_menu.asm:802-851 ShrinkPlayer
OakSpeech.SHRINK_MUSIC_FADE = 32
local SHRINK_PIC1 = 8
local SHRINK_PIC2 = 16
local SHRINK_CLEAR = 24
local SHRINK_ICON = 27
local SHRINK_END = 77
OakSpeech.SHRINK_PIC1 = SHRINK_PIC1
OakSpeech.SHRINK_PIC2 = SHRINK_PIC2
OakSpeech.SHRINK_CLEAR = SHRINK_CLEAR
OakSpeech.SHRINK_ICON = SHRINK_ICON
OakSpeech.SHRINK_END = SHRINK_END
-- ../pokecrystal/engine/menus/intro_menu.asm:945-948
local ICON_X, ICON_Y = 64, 60
local FALLBACKS = {
_OakText1 = Strings("Hello! Sorry to\nkeep you waiting!\fWelcome to the\nworld of POKéMON!\fMy name is OAK.\fPeople call me the\nPOKéMON PROF."),
_OakText2 = Strings("This world is in-\nhabited by crea-\vtures that we call\vPOKéMON."),
@@ -369,14 +385,25 @@ function OakSpeech:openInitClock()
local game = self.game
if not (game and game.stack) then return self:advance() end
self.busy = true
local faded = self.faded
self.faded = false
local pushed = Screens.push(game, "Gen2InitClock", {
mode = "clock",
save = game.save,
autoConfirm = self.autoConfirm,
fades = not self.autoConfirm,
faded = faded,
onDone = function()
game.stack:pop()
self.busy = false
self:advance()
-- ../pokecrystal/engine/menus/intro_menu.asm:629-635
if self.autoConfirm then
self.busy = false
return self:advance()
end
IntroFade.run(self, { "inBlack", "outWhite" }, function()
self.busy = false
self:advance()
end)
end,
})
if not pushed then
@@ -397,9 +424,12 @@ function OakSpeech:openGenderSelect(step)
self.busy = true
local pushed = Screens.push(game, "Gen2GenderSelect", {
save = game.save,
fades = not self.autoConfirm,
onDone = function(gender)
game.stack:pop()
self.busy = false
-- ../pokecrystal/engine/rtc/timeset.asm:22
self.faded = not self.autoConfirm
-- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default
-- NamePlayer lays down before the menu opens
-- (../pokecrystal/engine/menus/intro_menu.asm:768-781).
@@ -465,13 +495,28 @@ end
function OakSpeech:startShrink(step)
self.shrinkText = self:lastPageLines((step and step.textKey) or "_OakText7")
self.shrink = { frame = 0 }
self.playerIcon = nil
local data = self.game and self.game.data
-- ../pokecrystal/engine/menus/intro_menu.asm:806-812
Music.fadeOut(OakSpeech.SHRINK_MUSIC_FADE)
if data and data.audio and data.audio.sfx
and data.audio.sfx.Sfx_EscapeRope then
Sound.play(data, "Sfx_EscapeRope")
end
end
-- ../pokecrystal/engine/menus/intro_menu.asm:911-950 Intro_PlacePlayerSprite
function OakSpeech:overworldIcon()
local data = self.game and self.game.data
local sprites = data and data.gen2Sprites
local def = sprites and sprites[FieldMoves.playerSprite(self:gender())]
local image = def and tryImage(def.image)
if not image then return nil end
local colors = data.gen2Palettes
and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil
return { image = image, colors = colors }
end
-- A beat that produced a value: the name menu, and any choice/yesno a build
-- wrapper inserted. Same store and same event payload as Gen 1's, so a mod
-- reading answers off intro.oak_speech.answered needs no second listener.
@@ -637,6 +682,8 @@ function OakSpeech:finish()
end
function OakSpeech:update(_dt)
-- ../pokecrystal/home/fade.asm:22-101
if IntroFade.advance(self) then return end
local r = self.picReveal
if r then
r.t = r.t + 1
@@ -646,23 +693,26 @@ function OakSpeech:update(_dt)
end
return
end
-- ShrinkPlayer timeline (intro_menu.asm ShrinkPlayer): pic1 → pic2 →
-- clear → chris sprite beat → fade music → overworld.
-- ../pokecrystal/engine/menus/intro_menu.asm:820-851
local s = self.shrink
if not s then return end
s.frame = s.frame + 1
if s.frame == 8 then
if s.frame == SHRINK_PIC1 then
self.pic = self.shrinkPic1 or self.pic
elseif s.frame == 16 then
elseif s.frame == SHRINK_PIC2 then
self.pic = self.shrinkPic2 or self.pic
elseif s.frame == 24 then
elseif s.frame == SHRINK_CLEAR then
self.pic = nil
elseif s.frame == 32 then
Music.fadeOut(10)
elseif s.frame >= 80 then
elseif s.frame == SHRINK_ICON then
self.playerIcon = self:overworldIcon()
elseif s.frame >= SHRINK_END then
self.shrink = nil
self.shrinkText = nil
self:finish()
-- ../pokecrystal/engine/menus/intro_menu.asm:849-850
IntroFade.run(self, { "outWhite" }, function()
self.shrinkText = nil
self.playerIcon = nil
self:finish()
end)
end
end
@@ -700,34 +750,54 @@ function OakSpeech:drawPic()
G.setColor(1, 1, 1, 1)
end
-- ../pokecrystal/engine/menus/intro_menu.asm:911-950
function OakSpeech:drawPlayerIcon()
local icon = self.playerIcon
if not icon or not icon.image then return end
local G = love.graphics
G.setColor(1, 1, 1, 1)
local w, h = icon.image:getDimensions()
local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
w, h)
if icon.colors and GbcPalette.available() then
GbcPalette.with(icon.colors,
function() G.draw(icon.image, quad, ICON_X, ICON_Y) end)
else
G.draw(icon.image, quad, ICON_X, ICON_Y)
end
end
function OakSpeech:drawPanel()
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, 160, 144)
self:drawPic()
self:drawPlayerIcon()
if self.shrinkText and self.fontOk then
G.setColor(0, 0, 0, 1)
for i, line in ipairs(self.shrinkText) do
Font.draw(line, 16, 104 + (i - 1) * 16)
-- home/text.asm:142 PrintText -> SetUpTextbox -> SpeechTextbox
Chrome.textbox(0, 12, 18, 4)
local row = 14
for _, line in ipairs(self.shrinkText) do
if row <= 16 then Chrome.print(line, 1, row) end
row = row + 2
end
G.setColor(1, 1, 1, 1)
end
end
function OakSpeech:draw()
function OakSpeech:drawBody()
self:drawPanel()
IntroFade.paint(self, 160, 144)
end
-- ../pokecrystal/engine/menus/intro_menu.asm:875
function OakSpeech:draw()
Chrome.withClip(function() self:drawBody() end)
end
function OakSpeech:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawPanel()
G.pop()
local r, g, b = IntroFade.surround(self, 1, 1, 1)
Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end)
end
return OakSpeech
+7 -1
View File
@@ -419,6 +419,11 @@ local ROWS = {
text = function(options)
return Strings(require("src.core.LogicClock").label(options.logicClock))
end },
{ label = Strings.source("BATTLE LAYOUT"), key = "battleLayout", port = true,
values = { "og", "wide" },
display = {
og = Strings.source("OG "), wide = Strings.source("WIDE"),
} },
{ label = Strings.source("BATTLE SIZE"), key = "battleFit", port = true,
values = { "fixed", "fill" },
display = {
@@ -454,7 +459,8 @@ local GROUPS = {
{ id = "group.audio", label = Strings.source("AUDIO"),
members = { "sound", "musicVol", "sfxVol", "musicFilter" } },
{ id = "group.battle", label = Strings.source("BATTLE OPTIONS"),
members = { "battleScene", "battleStyle", "battleFit", "battleBg" } },
members = { "battleScene", "battleStyle", "battleLayout", "battleFit",
"battleBg" } },
{ id = "group.extras", label = Strings.source("EXTRAS"),
members = { "zoom", "voidFill", "tilt" } },
}
+10 -1
View File
@@ -648,7 +648,7 @@ function PartyMenu:showItemResult(slot, opts)
shown = opts.fromHp,
target = opts.toHp,
text = opts.text,
delay = PartyMenu.ACTION_TEXT_DELAY,
delay = opts.delay or PartyMenu.ACTION_TEXT_DELAY,
onDone = opts.onDone,
auto = opts.auto,
holdSlot = opts.holdSlot,
@@ -657,6 +657,15 @@ function PartyMenu:showItemResult(slot, opts)
if opts.sfx then self:playSfx(opts.sfx) end
end
-- ../pokecrystal/engine/battle/core.asm:5156
-- ../pokecrystal/home/text.asm:124
function PartyMenu:refuse(text)
self:closeSubmenu()
local lines = Chrome.wrap(text, 18)
for i = #lines, 3, -1 do lines[i] = nil end
self:showItemResult(nil, { text = table.concat(lines, "\n"), delay = 0 })
end
function PartyMenu:itemResultClimbing()
local r = self.itemResult
return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target
+64
View File
@@ -0,0 +1,64 @@
local Chrome = require("src.ui.gen2.Chrome")
local Strings = require("src.core.Strings")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
TILES_W = 38,
TILES_H = 18,
FIELD_X = 72,
EXTRA_TILES = 18,
}
function WideBattle.fillScale(winW, winH)
local w, h = winW or 0, winH or 0
local ok, Playfield = pcall(require, "src.render.Playfield")
if ok and Playfield.rect then
local okv, _, _, pw, ph = pcall(Playfield.rect, winW, winH)
if okv and pw and pw >= 1 and ph and ph >= 1 then
w, h = pw, ph
end
end
return math.max(1, math.min(w / WideBattle.WIDTH, h / WideBattle.HEIGHT))
end
function WideBattle.drawSurface(battle)
local G = love.graphics
Chrome.paletteFill(0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
if not battle:hasBattleSides() then
Chrome.printThrough(Strings("NO BATTLE"), 1, 1, Chrome.DEFAULT_BOX_PALETTE)
return
end
G.push()
G.translate(WideBattle.FIELD_X, 0)
battle:drawSceneBody(function()
Chrome.clear()
battle:drawPics()
end)
G.pop()
-- engine/battle/core.asm:4730
battle:drawEnemyHud()
-- engine/battle/core.asm:4592
G.push()
G.translate(WideBattle.EXTRA_TILES * 8, 0)
battle:drawPlayerHud()
G.pop()
battle:drawBottom(WideBattle.EXTRA_TILES)
end
function WideBattle.draw(battle, winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = battle:battlePanelScale(winW, winH)
local ox, oy = Chrome.fitOriginFor(winW, winH, scale, WideBattle.TILES_W,
WideBattle.TILES_H)
G.push("all")
Chrome.clipTo(ox, oy, WideBattle.WIDTH * scale, WideBattle.HEIGHT * scale)
G.translate(ox, oy)
G.scale(scale, scale)
battle:drawScene(function() WideBattle.drawSurface(battle) end)
G.pop()
end
return WideBattle
+6
View File
@@ -134,6 +134,12 @@ FieldDefaults.FIELD = {
lastMapRewrites = LAST_MAP_REWRITES,
-- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory
outsideTilesets = { "OVERWORLD", "PLATEAU" },
-- PlayMapChangeSound (home/overworld.asm:689-703)
-- of the cell being left; pokeyellow home/overworld.asm:667-671
mapChangeSound = {
doorTile = 0x0B,
exemptTilesets = { "FACILITY", "CEMETERY" },
},
-- the Route 16/18 gate scripts `res BIT_ALWAYS_ON_BIKE` every frame
forcedMovement = { clearMaps = { "ROUTE_16_GATE_1F", "ROUTE_18_GATE_1F" } },
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
-3
View File
@@ -143,15 +143,12 @@ end
-- ------- map record properties (authored maps set them; vanilla falls back)
-- town/route surface: door SFX, the walk-out step, the Fly menu and the
-- town map all mean this one
function Map.isOutdoor(def)
if def.outdoor ~= nil then return def.outdoor end
return def.tileset == "OVERWORLD"
end
-- CheckIfInOutsideMap, a strictly wider set: Route 23 / Indigo Plateau are
-- outside for the wLastMap memory without being outdoor for the door SFX
function Map.isOutside(def, tilesets)
if Map.isOutdoor(def) then return true end
for _, ts in ipairs(tilesets or OUTSIDE_TILESETS) do
+9 -4
View File
@@ -1,6 +1,4 @@
-- Map object (NPC/item) built from a generated object_event entry.
-- STAY objects keep their facing; WALK objects wander randomly within the
-- roam constraint (ANY_DIR / UP_DOWN / LEFT_RIGHT), like the original.
local Collision = require("src.world.Collision")
local SpriteRenderer = require("src.render.SpriteRenderer")
@@ -14,8 +12,10 @@ local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
}
-- .randomMovement -- pokered engine/overworld/movement.asm:190
local ROAM_DIRS = {
ANY_DIR = { "up", "down", "left", "right" },
NONE = { "up", "down", "left", "right" },
UP_DOWN = { "up", "down" },
LEFT_RIGHT = { "left", "right" },
}
@@ -36,8 +36,11 @@ function NPC.new(data, mapId, objDef)
self.animClock = 0
self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK"
self.roamDirs = ROAM_DIRS[objDef.range] or ROAM_DIRS.ANY_DIR
-- pokered engine/overworld/movement.asm:611
local turnDirs = ROAM_DIRS[objDef.range or "NONE"]
self.steps = objDef.movement == "WALK"
self.wanders = (self.steps or objDef.movement == "STAY") and turnDirs ~= nil
self.roamDirs = turnDirs or ROAM_DIRS.ANY_DIR
self.timer = love.math.random(30, 120)
return self
end
@@ -111,6 +114,8 @@ function NPC:update(map, entities)
self.timer = love.math.random(30, 180)
local dir = self.roamDirs[love.math.random(#self.roamDirs)]
self.facing = dir
-- pokered engine/overworld/movement.asm:262
if not self.steps then return end
if love.math.random() < 0.5 then return end -- sometimes just turn
-- never wander onto warps, so NPCs don't walk out of the map
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
+259 -43
View File
@@ -10,6 +10,7 @@ local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults")
local Flags = require("src.script.Flags")
local GameVersion = require("src.core.GameVersion")
local GbcPalette = require("src.render.GbcPalette")
local Logger = require("src.core.Logger")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
@@ -73,6 +74,24 @@ local FLY_PATH_IN = {
{ 0x39, 0x58 }, { 0x3B, 0x50 }, { 0x3C, 0x48 }, { 0x3C, 0x40 },
}
-- engine/overworld/player_animations.asm:126
local TELEPORT_OUT_HOLDS = {}
for d = 15, 0, -1 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = d end
for i = 1, 6 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = i < 6 and 3 or 0 end
local TELEPORT_OUT_INPLACE = 16
local TELEPORT_OUT_FRAMES = 135
local TELEPORT_OUT_SFX = {
[4] = "Teleport_Exit2", [8] = "Teleport_Exit2", [12] = "Teleport_Exit2",
[TELEPORT_OUT_INPLACE] = "Teleport_Exit1",
}
-- counting 0 up to 8 -- engine/overworld/player_animations.asm:20
local TELEPORT_IN_HOLDS = { 3, 3, 3, 3, 3, 0, 1, 2, 3, 4, 5, 6, 7, 0 }
local TELEPORT_IN_FRAMES = 43
local SPIN_DOWN_STEPS = 6
-- engine/overworld/player_animations.asm:41-45
local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 }
local HOLE_IN_FRAMES = 15
-- healing machine ball screen positions (PokeCenterOAMData dbsprite
-- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin
-- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP
@@ -498,6 +517,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
self.player.slopeMap = self:onSlopeMap(mapId)
-- boot only: the original persists the surf state. wWalkBikeSurfState
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
-- engine/menus/save.asm block-copies into sMainData on save and back out
@@ -810,11 +830,7 @@ function OverworldState:sgbPalettes()
return PaletteFX.wholeNamed(Game.data, mapName)
end
-- World-pass palette zones in world-canvas pixels: each visible map
-- area keeps its own SGB palette (a deliberate step past the original,
-- which recolored the whole screen per map -- see the survey zoom
-- entry in docs/known-differences.md). Border fill inherits the
-- current map's palette.
-- `ld de, BlkPacket_WholeScreen` (engine/gfx/palettes.asm:178)
--
-- RED++ true overworld coloring does NOT go through this zone/shader
-- system at all: TileRenderer bakes real per-tile GBC colors straight into
@@ -829,6 +845,11 @@ end
-- would re-run the DMG shade-remap over already-true-color pixels using
-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this
-- fixes.
-- engine/gfx/palettes.asm:178
function OverworldState.perMapWorldPalettes()
return (Zoom.offset or 0) < 0
end
function OverworldState:sgbWorldZones()
local PaletteFX = require("src.render.PaletteFX")
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
@@ -839,6 +860,7 @@ function OverworldState:sgbWorldZones()
local vw, vh = Game.renderer:worldViewSize()
local cam = self.camera
local zones = { { colors = base, x = 0, y = 0, w = vw, h = vh } }
if not OverworldState.perMapWorldPalettes() then return zones end
for _, nb in ipairs(self.neighbors) do
local colors = PaletteFX.pal(Game.data, self:paletteNameFor(nb.map))
if colors then
@@ -1153,6 +1175,7 @@ function OverworldState:update(dt)
-- keep the player sprite in sync with the bike state (the drawer
-- picks the red_bike sheet while riding)
self.player.onBike = Game.save.onBike
self.player.slopeMap = self:onSlopeMap()
-- the rendered neighbor set depends on the view size; zooming out (or
-- resizing) past what setMap computed re-runs the walk in place
if self.map and (self.neighborViewW or 0) > 0 then
@@ -1295,7 +1318,8 @@ function OverworldState:update(dt)
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive
or self.teleportOut or self.transitioning or self.player.spinning) then
or self.teleportOut or self.transitioning or self.player.spinning
or self.holeFall or self.holeArrive) then
self:showPikachuAfterWarp()
end
@@ -1307,6 +1331,13 @@ function OverworldState:update(dt)
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
-- decrements in lockstep in Player:update, so the rising spin ends here too.
if self.teleportOut then
-- engine/overworld/player_animations.asm:136
local step = self.player.spinStep or 0
if step ~= self.teleportOut.step then
self.teleportOut.step = step
local key = TELEPORT_OUT_SFX[step]
if key then require("src.core.Sound").play(Game.data, key) end
end
self.teleportOut.frames = self.teleportOut.frames - 1
if self.teleportOut.frames <= 0 then
local onDone = self.teleportOut.onDone
@@ -1314,6 +1345,9 @@ function OverworldState:update(dt)
self.player.spinning = false
self.player.spinFrames = nil
self.player.spinRise = nil
self.player.spinHolds = nil
self.player.spinStep = nil
self.player.spinRiseFrom = nil
self.player.inputLocked = false
-- keep the sprite hidden through the warp fade-out (#916): the spin is
-- over but the arrival spin-drop is not armed until startWarpTo's
@@ -1324,6 +1358,39 @@ function OverworldState:update(dt)
end
end
-- fades to white -- engine/overworld/player_animations.asm:204-228
if self.holeFall then
self.holeFall.frames = self.holeFall.frames - 1
if self.holeFall.frames <= 0 then
local f = self.holeFall
self.holeFall = nil
self.player.holeSink = nil
self.playerHidden = true
self.arriveWarp = "hole"
self:startWarpTo(f.map, f.x, f.y, f.facing)
return
end
end
-- offscreen, then spins him down -- player_animations.asm:41-45
if self.holeArrive then
self.holeArrive.frames = self.holeArrive.frames - 1
if self.holeArrive.frames <= 0 then
self.holeArrive = nil
self.playerHidden = false
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = HOLE_IN_FRAMES
self.player.spinTotal = HOLE_IN_FRAMES
self.player.spinDrop = true
self.player.spinHolds = HOLE_IN_HOLDS
self.player.spinStep = 0
self.player.spinHold = HOLE_IN_HOLDS[1]
self.player.spinDropSteps = SPIN_DOWN_STEPS
self.spinArrive = true
end
end
-- delayed one-shot SFX (the teleport-in spin's second note)
if self.delaySfx then
self.delaySfx.frames = self.delaySfx.frames - 1
@@ -1371,6 +1438,8 @@ function OverworldState:update(dt)
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive or self.spinArrive
or self.holeFall or self.holeArrive
or self.holeFall or self.holeArrive
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1381,6 +1450,7 @@ function OverworldState:update(dt)
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive or self.spinArrive
or self.holeFall or self.holeArrive
end
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
@@ -1570,19 +1640,34 @@ function OverworldState:handleInput()
-- promises ("Press the A or B Button to stay in place") and what the
-- edge-only wasPressed("a") above can never deliver, since a press
-- stalls the roll for one frame only (issue #255).
local fm = Game.data.field.forcedMovement
local braking = input:isDown("a") or input:isDown("b")
if fm and Game.save.onBike and not braking and not self.player.moving then
for _, m in ipairs(fm.slopeMaps or {}) do
if m == self.map.id then
self.player.facing = "down"
self.player:tryMove("down", self.map, self.entities)
return
end
if Game.save.onBike and not braking and not self.player.moving
and self:onSlopeMap() then
-- does -- home/overworld.asm:1817
self.player.facing = "down"
if self:checkEdgeExit("down") then return end
if self:checkLedgeHop("down") then return end
if self:checkBoulderPush("down") then return end
local result = self.player:tryMove("down", self.map, self.entities)
if result == "blocked" and self:canCollisionWarp() then
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
self.player.cellX, self.player.cellY, "down")
if w then self:takeWarp(w.def) end
end
return
end
end
-- JoypadOverworld both make -- home/overworld.asm:382
function OverworldState:onSlopeMap(mapId)
local fm = Game.data.field.forcedMovement
mapId = mapId or (self.map and self.map.id)
for _, m in ipairs((fm and fm.slopeMaps) or {}) do
if m == mapId then return true end
end
return false
end
-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder):
-- walking into one with STRENGTH in the party pushes it one cell, but
-- only on the second consecutive push attempt (BIT_TRIED_PUSH_BOULDER);
@@ -1892,7 +1977,7 @@ function OverworldState:crossConnection(dir, conn)
-- fresh walk-cycle clock so the seam step always shows leg frames
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = p:stepLength()
p.stepFramesCur = p:stepLength(dir)
require("src.core.FixedStep"):discardCatchup()
return true
end
@@ -2216,7 +2301,8 @@ function OverworldState:beginTeleportOut(onDone)
-- StopMusic sits above both _LeaveMapAnim branches
-- engine/overworld/player_animations.asm:123
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
-- engine/overworld/player_animations.asm:136
require("src.core.Sound").play(Game.data, "Teleport_Exit2")
self.player.surfing = false
self:syncSurfingPikachu()
self:hidePikachuForWarp()
@@ -2225,10 +2311,14 @@ function OverworldState:beginTeleportOut(onDone)
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinFrames = TELEPORT_OUT_FRAMES
self.player.spinTotal = TELEPORT_OUT_FRAMES
self.player.spinRise = true
self.teleportOut = { frames = 48, onDone = onDone }
self.player.spinHolds = TELEPORT_OUT_HOLDS
self.player.spinStep = 0
self.player.spinHold = TELEPORT_OUT_HOLDS[1]
self.player.spinRiseFrom = TELEPORT_OUT_INPLACE - 1
self.teleportOut = { frames = TELEPORT_OUT_FRAMES, onDone = onDone, step = 0 }
end
function OverworldState:npcAtCell(cx, cy)
@@ -2801,22 +2891,29 @@ function OverworldState:billsHousePC()
or romText(Game.data, "_BillsHouseMonitorText", "TELEPORTER is\ndisplayed on the\nPC monitor.")))
return
end
require("src.core.Music").stop()
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
or Strings("{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"), function()
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
require("src.core.Sound").play(Game.data, "Switch")
-- BillsHouseInitiatedText's text_asm stops the music only after the
-- prompt button -- bills_house_pc.asm:55
require("src.core.Music").stop()
self:queueScript({
{ "wait", 16 },
{ "play_sound", "Switch" }, { "wait_sound" },
{ "wait", 60 },
-- .doCellSeparator -- bills_house_pc.asm:18
{ "wait", 32 },
{ "play_sound", "Tink" },
{ "play_sound", "Tink" }, { "wait_sound" },
{ "wait", 80 },
{ "play_sound", "Shrink" },
{ "play_sound", "Shrink" }, { "wait_sound" },
{ "wait", 48 },
{ "play_sound", "Tink" },
{ "play_sound", "Tink" }, { "wait_sound" },
{ "wait", 32 },
{ "play_sound", "Get_Item1" },
{ "wait", 30 },
}, { onDone = function() self:billsHouseBillExits() end })
{ "play_sound", "Get_Item1" }, { "wait_sound" },
}, { onDone = function()
-- bills_house_pc.asm:39
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
self:billsHouseBillExits()
end })
end))
end
@@ -4972,16 +5069,22 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing)
return
elseif pad == "hole" then
-- falling through a hole: Faint_Fall plays while the player drops,
-- matching the boulder-hole Faint_Thud at line 3618 (#694)
require("src.core.Sound").play(Game.data, "Faint_Fall")
self:startWarpTo(destMap, x, y, facing)
self:fallThroughHole(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
self:startWarpTo(destMap, x, y, facing)
end
-- (engine/overworld/player_animations.asm:204-228)
function OverworldState:fallThroughHole(mapId, x, y, facing)
self.player.inputLocked = true
self:hidePikachuForWarp()
self.player.holeSink = true
self.holeFall = { frames = 2, map = mapId, x = x, y = y,
facing = facing or self.player.facing }
end
-- Remember the outdoor side for LAST_MAP exits (pokered's wLastMap).
function OverworldState:rememberOutdoor(id, x, y)
self.lastOutdoor = { id = id, x = x, y = y }
@@ -5056,20 +5159,35 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
if doorWarp then
local dest = Game.data.maps[mapId]
local outdoor = dest and Map.isOutdoor(dest)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- the source map -- home/overworld.asm:689-703
local rule = FieldDefaults.field(Game.data, "mapChangeSound") or {}
local exempt = false
-- pokeyellow home/overworld.asm:667-671
if GameVersion.isYellow() then
for _, ts in ipairs(rule.exemptTilesets or {}) do
if ts == self.map.def.tileset then exempt = true end
end
end
local cue = "Go_Outside"
if not exempt
and self.map:tileAt(self.player.cellX * 2, self.player.cellY * 2)
== (rule.doorTile or 0x0B) then
cue = "Go_Inside"
end
require("src.core.Sound").play(Game.data, cue)
end
-- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite
-- (player_animations.asm). Door warps stay black with no fade-in (#1644).
local Timing = require("src.core.Timing")
local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport"
or arriveWarp == "hole"
local fadeOpts = specialWarp and {
color = { 1, 1, 1 },
frames = Timing.FADE_OUT_TO_WHITE,
framesIn = Timing.FADE_IN_FROM_WHITE,
} or nil
-- home/overworld.asm:681
if not specialWarp and self.dark then fadeOpts = { frames = 0 } end
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
@@ -5100,18 +5218,28 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.flyArrive = { t = 0 }
elseif arriveWarp == "teleport" then
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later
self.delaySfx = { frames = 40, key = "Teleport_Enter2" }
-- engine/overworld/player_animations.asm:83-85
self.delaySfx = { frames = HOLE_IN_FRAMES, key = "Teleport_Enter2" }
-- the sprite spins down into place (EnterMapAnim
-- PlayerSpinWhileMovingDown), not just the SFX
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinFrames = TELEPORT_IN_FRAMES
self.player.spinTotal = TELEPORT_IN_FRAMES
self.player.spinDrop = true
self.player.spinHolds = TELEPORT_IN_HOLDS
self.player.spinStep = 0
self.player.spinHold = TELEPORT_IN_HOLDS[1]
self.player.spinDropSteps = SPIN_DOWN_STEPS
-- engine/overworld/player_animations.asm:19
self.spinArrive = true
self.player.inputLocked = true
elseif arriveWarp == "hole" then
-- engine/overworld/player_animations.asm:12
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
self.playerHidden = true
self.holeArrive = { frames = Timing.DUNGEON_WARP_ARRIVAL }
self.player.inputLocked = true
end
if doorWarp then
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
@@ -5308,9 +5436,88 @@ end
-- draw / save
-- -------------------------------------------------------------------------
-- home/fade.asm:58
function OverworldState.warpFade()
local top = Game and Game.stack and Game.stack.top and Game.stack:top()
if top and top.bgp and top.shadeMapFor then return top end
return nil
end
function OverworldState:bakedWorldColors()
return PaletteFX.usesGbcPack() and self.map and self.map.renderer
and self.map.renderer.gbcAtlas and true or false
end
-- pokeyellow engine/gfx/palettes.asm:799
-- pokeyellow home/fade.asm:75-77
function OverworldState:warpFadeUniforms(byte)
local pack = PaletteFX.worldPack()
if not pack then return nil end
local bg, seen = {}, {}
local function addMap(map)
local tileset = map and map.def and map.def.tileset
if not tileset or seen[tileset] then return end
seen[tileset] = true
local groups = PaletteFX.worldGroupColors(Game.data, tileset, map.id,
self.player and self.player.cellY)
for _, colors in ipairs(groups or {}) do bg[#bg + 1] = colors end
end
addMap(self.map)
if not bg[1] then return nil end
for group = 0, 7 do
local colors = (pack.spritePalettes or {})[group]
if colors then bg[#bg + 1] = PaletteFX.darkObp(colors, group) end
end
for _, nb in ipairs(self.neighbors or {}) do addMap(nb.map) end
return GbcPalette.remapUniforms(bg, byte)
end
-- home/fade.asm:58
function OverworldState:drawWorldFaded()
local fade = OverworldState.warpFade()
if not (fade and self:bakedWorldColors()) then return false end
local byte = fade:bgp()
if not Transition.shadeMapFor(byte) then return false end
if Tilt.active() or Pipelines.worldPipeline() then return false end
if GbcPalette.mode ~= "gbc" or GbcPalette.customRamp then return false end
if not GbcPalette.remapShader() then return false end
local target = Game.renderer and Game.renderer.worldCanvas
if not target then return false end
local w, h = target:getWidth(), target:getHeight()
local scratch = self.fadeCanvas
if scratch and (scratch:getWidth() ~= w or scratch:getHeight() ~= h) then
if scratch.release then scratch:release() end
scratch, self.fadeCanvas = nil, nil
end
if not scratch then
local ok, made = pcall(love.graphics.newCanvas, w, h)
if not ok or not made then return false end
made:setFilter("nearest", "nearest")
scratch = made
self.fadeCanvas = scratch
end
local uniforms = self:warpFadeUniforms(byte)
if not uniforms then return false end
local G = love.graphics
local previous = G.getCanvas()
G.push()
G.origin()
G.setCanvas(scratch)
G.clear(1, 1, 1, 1)
self:drawWorld()
G.setCanvas(previous)
G.pop()
if not GbcPalette.useRemapUniforms(uniforms) then return false end
G.setColor(1, 1, 1, 1)
G.draw(scratch, 0, 0)
GbcPalette.clear()
fade.paletteStepped = true
return true
end
function OverworldState:draw()
Game.renderer:beginWorldPass()
self:drawWorld()
if not self:drawWorldFaded() then self:drawWorld() end
Game.renderer:endWorldPass()
self:drawUI()
end
@@ -5434,8 +5641,17 @@ function OverworldState:drawWorld()
local battleOverWorld = Game and Game.stack
and Game.worldBgBattleInStack(Game.stack)
-- home/fade.asm:66
PaletteFX.setShadeMap((self.dark and not battleOverWorld)
and PaletteFX.DARK_BGP or self:poisonShadeMap())
-- home/overworld.asm:684
local fade = OverworldState.warpFade()
local fadeBgp = fade and fade:bgp()
local fadeMap = fadeBgp and Transition.shadeMapFor(fadeBgp)
if fadeBgp and not self:bakedWorldColors() then
PaletteFX.setShadeMap(fadeMap)
fade.paletteStepped = true
else
PaletteFX.setShadeMap((self.dark and not battleOverWorld)
and PaletteFX.DARK_BGP or self:poisonShadeMap())
end
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
+109 -17
View File
@@ -157,7 +157,9 @@ local function makeFollower(game, ow, x, y, facing)
movement = "STAY", range = "NONE", x = x, y = y,
})
npc.pikachuFollower = true
npc.passable = true -- never blocks a step (Collision.occupied)
npc.wanders = false -- scripted facing only, never the STAY idle roll (#2117)
-- pokeyellow engine/pikachu/pikachu_follow.asm:26
npc.passable = true
npc.facing = facing or "down"
-- the idle animations below pose the walk cycle with no step under it,
-- which NPC:walkPhase (moving-only) cannot express. An instance field
@@ -418,13 +420,70 @@ function PikachuFollower.rebase(ow, dx, dy)
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
end
local DIRS = { "up", "down", "left", "right" }
-- wPikachuCollisionCounter: 8 on a direction change (home/overworld.asm:189),
-- cleared with no d-pad held (:130) and once a step commits (:242)
local function tickCollisionCounter(game, ow, npc)
local input = game.input
local p = ow.player
local dir
if input then
for _, d in ipairs(DIRS) do
if input:isDown(d) then dir = d break end
end
end
if not dir then
ow.pikachuCollisionCounter = 0
if ow.pikachuMovingDir then
ow.pikachuLastStopDir = ow.pikachuMovingDir
ow.pikachuMovingDir = nil
end
ow.pikachuTurnArmed = true
return
end
if ow.pikachuTurnArmed and dir ~= ow.pikachuLastStopDir then
ow.pikachuCollisionCounter = 8
ow.pikachuTurnArmed = false
ow.pikachuMovingDir = dir
return
end
ow.pikachuMovingDir = dir
if p.moving then
ow.pikachuCollisionCounter = 0
return
end
local n = ow.pikachuCollisionCounter or 0
if n <= 0 then return end
local tx, ty = Collision.target(p.cellX, p.cellY, dir)
if p.facing == dir and npc.cellX == tx and npc.cellY == ty then
ow.pikachuCollisionCounter = n - 1
end
end
-- CollisionCheckOnLand's Pikachu branch -- home/overworld.asm:1234-1252
local function updatePassable(game, ow, npc)
if PikachuFollower.isFollowingDisabled(ow) then
npc.passable = false
ow.pikachuCollisionCounter = 0
return
end
tickCollisionCounter(game, ow, npc)
if game.input and game.input:isDown("b") then
npc.passable = true
return
end
npc.passable = (ow.pikachuCollisionCounter or 0) <= 0
end
-- one follow step per frame: chase the cell the player last vacated
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
if ow.pikachuBillsScene or ow.pikachuFanClubScene
or ow.pikachuPewterSleepScene then return end
local npc = findFollower(ow)
-- home/overworld.asm:1238-1240
if npc then updatePassable(game, ow, npc) end
if PikachuFollower.isFollowingDisabled(ow) then return end
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
return
@@ -681,10 +740,16 @@ end
-- MapSpecificPikachuExpression + TalkToPikachu's selection order
local function selectEmotion(game, ow, save)
local mapId = ow.map.id
-- Fan Club / Pewter Center map beats (the Bill's-house event variant
-- is owned by that map's script)
if mapId == "POKEMON_FAN_CLUB" then return 30 end
if mapId == "PEWTER_POKECENTER" then return 26 end
-- BillsHouse_CheckPikachuEmotion -- scripts/BillsHouse_2.asm:88
if mapId == "BILLS_HOUSE" then
if ow.pikachuBillsScene
and not save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
return 23
end
return save.flags.EVENT_MET_BILL_2 and 31 or 32
end
local starter = PikachuFollower.starterInParty(save)
if starter then
if starter.status == "SLP" then return 11 end
@@ -707,6 +772,8 @@ local function bubbleIndex(game, name)
return nil
end
local playEmotion
function PikachuFollower.talk(game, ow, npc, done)
-- pikachu_follow.asm steps the follower on the player's own walk clock,
-- so it is never mid-tile while the player stands and can always be
@@ -733,6 +800,13 @@ function PikachuFollower.talk(game, ow, npc, done)
if finish then finish() end
end
end
return playEmotion(game, ow, npc, emotion, { skippable = true, onDone = done })
end
-- engine/pikachu/pikachu_emotions.asm:14
function playEmotion(game, ow, npc, emotion, opts)
opts = opts or {}
local done = opts.onDone
local e = EMOTIONS[emotion] or EMOTIONS[1]
if e.turnAway then
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
@@ -766,8 +840,10 @@ function PikachuFollower.talk(game, ow, npc, done)
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done,
pikaSeq = anim.seq, pikaTotal = hold, skippable = opts.skippable,
onDone = done,
}
return ow.emote
end
-- Where the framed pic sits this frame. The overlay a pikaframe run draws
@@ -795,17 +871,25 @@ end
-- the cell separator, then reacts when Bill reappears. Keep it at the
-- machine until this map instance is discarded, just like the cartridge's
-- disabled following state.
local function billsHouseEmotion(game, ow, npc, bubble)
local Sprites = require("src.pokemon.Sprites")
-- (engine/overworld/emotion_bubbles.asm:60)
-- engine/pikachu/pikachu_emotions.asm:14
local function billsHouseEmotion(game, ow, npc, bubble, emotion, done)
local function anim()
playEmotion(game, ow, npc, emotion, { onDone = done })
end
if not bubble then return anim() end
ow.emote = {
npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false,
pikaPic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" }),
npc = npc, frames = 60, bubble = bubbleIndex(game, bubble) or false,
onDone = anim,
}
end
-- pokeyellow engine/pikachu/pikachu_movement.asm:208-229
local PIKA_STEP_FRAMES = 16
local function movePikachu(ow, npc, steps, onDone)
npc.goalX, npc.goalY = nil, nil
npc.stepFrames = PIKA_STEP_FRAMES
idleReset(npc)
local function nextStep(i)
local step = steps[i]
@@ -836,7 +920,7 @@ function PikachuFollower.onFanClubEntered(game, ow)
break
end
end
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 30)
movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function()
npc.facing = "up"
end)
@@ -865,8 +949,15 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
local npc = findFollower(ow)
if not npc then return end
ow.pikachuBillsScene = true
movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function()
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
local steps = { { "right", 3 }, { "up", 1 } }
-- engine/pikachu/pikachu_follow.asm:59
if npc.cellX == ow.player.cellX and npc.cellY == ow.player.cellY
and Collision.canMove(ow.map, ow.entities, npc, "right") then
table.insert(steps, 1, { "right", 1 })
end
movePikachu(ow, npc, steps, function()
-- BillsHouse_CheckPikachuEmotion SCRIPT0 -- scripts/BillsHouse_2.asm:88
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23)
end)
end
@@ -928,9 +1019,9 @@ function PikachuFollower.onBillEnteredMachine(game, ow)
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
or { { "up", 3 } }
movePikachu(ow, npc, steps, function()
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
-- no EmotionBubble predef -- scripts/BillsHouse.asm:100
npc.facing = "up"
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
billsHouseEmotion(game, ow, npc, nil, 32)
end)
end
@@ -940,7 +1031,8 @@ function PikachuFollower.onBillExitedMachine(game, ow)
if not npc then return end
idleReset(npc)
npc.facing = "left"
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
-- BillsHouse_CheckPikachuEmotion SCRIPT5 -- scripts/BillsHouse_2.asm:88
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 27)
end
-- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the
+36 -3
View File
@@ -113,14 +113,20 @@ end
-- the bicycle doubles walking speed (8 frames per step); movement.speed
-- lets a mod multiply or replace that (running shoes, dash, etc.)
-- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283
function Player:stepLength()
function Player:stepLength(dir)
local Game = require("src.core.Game")
local save = Game.save
local onBike = (save and save.onBike and not self.ledgeHop) or false
local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES
-- held, so those three cost a walking step -- home/overworld.asm:377
dir = dir or self.facing
local slope = onBike and self.slopeMap and dir ~= "down" or false
if slope then frames = self.stepFrames or STEP_FRAMES end
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = onBike,
slope = slope and true or false,
dir = dir,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
@@ -165,7 +171,7 @@ function Player:tryMove(dir, map, entities)
self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0
self.stepFramesCur = self:stepLength()
self.stepFramesCur = self:stepLength(dir)
return "moved"
end
@@ -184,6 +190,14 @@ function Player:update()
end
if self.spinning then
self.spinTimer = (self.spinTimer or 0) + 1
-- engine/overworld/player_animations.asm:298
if self.spinHolds then
self.spinHold = (self.spinHold or 0) - 1
while self.spinHold <= 0 and (self.spinStep or 0) < #self.spinHolds - 1 do
self.spinStep = (self.spinStep or 0) + 1
self.spinHold = self.spinHolds[self.spinStep + 1]
end
end
end
if self.spinFrames then
self.spinFrames = self.spinFrames - 1
@@ -192,6 +206,11 @@ function Player:update()
self.spinDrop = nil
self.spinRise = nil -- teleport-out departure lift (#196)
self.spinning = false
self.spinHolds = nil
self.spinStep = nil
self.spinHold = nil
self.spinRiseFrom = nil
self.spinDropSteps = nil
end
end
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
@@ -287,7 +306,19 @@ function Player:pose()
-- fixed-rate animation clock so the bike's shorter steps don't double
-- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then
if self.spinning and self.spinHolds then
-- engine/overworld/player_animations.asm:279
local step = self.spinStep or 0
facing = SPIN_ORDER[step % 4 + 1]
phase, flip = 0, false
-- engine/overworld/player_animations.asm:319
if self.spinRiseFrom and step > self.spinRiseFrom then
py = py - (step - self.spinRiseFrom) * 16
elseif self.spinDropSteps then
local left = self.spinDropSteps - step
if left > 0 then py = py - left * 16 end
end
elseif self.spinning then
-- spinners.asm:1-11, home/overworld.asm:41-44, :268-272
facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1]
phase, flip = 0, false
@@ -304,6 +335,8 @@ function Player:pose()
py = py - math.floor((total - self.spinFrames) * 24 / total)
end
end
-- engine/overworld/player_animations.asm:204
if self.holeSink then py = py + 8 end
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
-- across FishingAnim, so casting from the water shows the on-foot sheet
local sprite = (self.fishing and self.sprite)
+17 -2
View File
@@ -134,6 +134,11 @@ function Player:tryMove(dir, map, entities)
return "moved"
end
-- engine/overworld/player_movement.asm:807
function Player:stopForEvent()
self.bumpFrames = nil
end
-- Cutscene step: ignores collision so Elm walk-up / after-pick paths play.
function Player:scriptFace(dir)
if dir then self.facing = dir end
@@ -175,9 +180,11 @@ end
local SPIN_FACINGS = { "down", "right", "up", "left" }
local SPIN_START = { down = 0, right = 1, up = 2, left = 3 }
function Player:scriptSpin(frames)
-- map_objects.asm:1481-1493, map_object_action.asm:133
function Player:scriptSpin(frames, flicker)
if not frames or frames <= 0 then return end
self.spinFrames = frames
self.spinFlicker = flicker or nil
self.spinTimer = (SPIN_START[self.facing] or 0) * 4
end
@@ -216,7 +223,10 @@ function Player:update()
if self.spinFrames then
self.spinTimer = (self.spinTimer or 0) + 1
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then self.spinFrames = nil end
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinFlicker = nil
end
end
if not self.moving then
-- map_objects.asm:1517-1525
@@ -305,6 +315,11 @@ function Player:draw(ox, oy, scale, oamRow)
local flip = self:drawFlip()
-- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig.
if self.spinFrames then
-- map_objects.asm:1481-1493
if self.spinFlicker and self.spinFrames % 2 == 1 then
G.pop()
return
end
facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1]
phase = 0
end
+202 -46
View File
@@ -88,6 +88,8 @@ local SFX = {
FLASH = 169,
ENTER_DOOR = 31,
WARP_TO = 19,
WARP_FROM = 20,
KINESIS = 47,
EXIT_BUILDING = 35,
JUMP_OVER_LEDGE = 0x16,
BUMP = 0x24,
@@ -159,8 +161,16 @@ local BATTLETYPE = {
local COLL = {
DOOR = 0x71,
WARP_PANEL = 0x7c,
-- CheckPitTile (home/map_objects.asm:162)
-- engine/overworld/events.asm:349-353
PIT = 0x60,
PIT_68 = 0x68,
}
local function isPitCollision(coll)
return coll == COLL.PIT or coll == COLL.PIT_68
end
local SPRITE = {
VARS = 0xf0,
DAY_CARE_MON_1 = 0xe0,
@@ -190,6 +200,8 @@ local MAPSETUP = {
local MAPSETUP_FADE_OUT = {
[MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true,
}
-- data/maps/setup_scripts.asm:27-32
local MAPSETUP_WARP_WINDOW = { [MAPSETUP.TELEPORT] = true }
local MAPSETUP_FADE_IN = {
[MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true,
[MAPSETUP.WARP] = true, [MAPSETUP.BADWARP] = true, [MAPSETUP.TRAIN] = true,
@@ -233,8 +245,10 @@ local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true }
-- World:draw already holds for the fade specials.
local FADE_STEPS = 4
local FADE_STEP_FRAMES = 2
-- data/maps/setup_scripts.asm:102-139
local MAP_LOAD_WHITE_FRAMES = 4
-- data/maps/setup_scripts.asm:102-125, home/init.asm:149-153
local MAP_LOAD_WHITE_FRAMES = 13
-- data/maps/setup_scripts.asm:32-55
local WARP_LOAD_WHITE_FRAMES = 15
-- home/map.asm:1927-1940 over home/tilemap.asm:12-25
local MENU_EXIT_RELOAD_FRAMES = 9
@@ -1513,6 +1527,8 @@ function World:busy()
-- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript
-- (engine/events/overworld.asm:599, :605).
or self.flyAnim ~= nil
-- engine/overworld/events.asm:1011
or self.skyfall ~= nil
end
-- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput,
@@ -2299,6 +2315,40 @@ function World:updateShake()
shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude
end
-- :1008-1021. StepFunction_Skyfall (engine/overworld/map_objects.asm:1368)
local SKYFALL_BEAT_FRAMES = 16
local PITFALL_EARTHQUAKE = 0x10
function World:startSkyfall()
self:playSfxNamed("Sfx_Kinesis", SFX.KINESIS)
self.skyfall = { phase = "hidden", timer = SKYFALL_BEAT_FRAMES, height = 0 }
self.playerMasked = true
end
function World:updateSkyfall()
local st = self.skyfall
if not st then return end
if st.phase == "hidden" then
st.timer = st.timer - 1
if st.timer <= 0 then
st.phase = "fall"
st.timer = SKYFALL_BEAT_FRAMES
self.playerMasked = nil
end
return
end
st.height = st.height + 1
if self.player then
self.player.spriteYOffset = Movement.teleportYOffset(st.height)
end
st.timer = st.timer - 1
if st.timer > 0 then return end
if self.player then self.player.spriteYOffset = 0 end
self.skyfall = nil
self:playSfxNamed("Sfx_Strength", SFX.STRENGTH)
self:earthquake(PITFALL_EARTHQUAKE)
end
-- engine/events/poisonstep_pals.asm:9
function World:poisonBGFlash()
self.poisonFlash = 4
@@ -3501,10 +3551,14 @@ function World:screenFade(kind)
-- kind: "outWhite" | "outBlack" | "inWhite" | "inBlack"
if kind == "inWhite" or kind == "inBlack" then
self.fade, self.fadeLevel = nil, nil
self.fadeHold, self.fadeWhiten = nil, nil
return
end
self.fade = (kind == "outWhite") and "white" or "black"
self.fadeLevel = 1
self.fadeHold = nil
-- engine/tilesets/timeofday_pals.asm:122-128
self.fadeWhiten = (kind == "outWhite") or nil
end
-- RunMapSetupScript (engine/overworld/map_setup.asm): every map entry runs one
@@ -3540,12 +3594,19 @@ function World:runMapSetup(method, load, fly)
-- only the way back in is a fade.
local ok = wrapped()
self.fade, self.fadeLevel = "white", 1
self.fadeWhiten = nil
self.fadeHold = WARP_LOAD_WHITE_FRAMES
self.mapSetup = { phase = "in", step = FADE_STEPS,
wait = MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES }
wait = WARP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES }
return ok
end
-- engine/tilesets/timeofday_pals.asm:122-128
self.fadeWhiten = true
self.fadeHold = nil
self.mapSetup = {
phase = "out", step = 0, wait = FADE_STEP_FRAMES, load = wrapped,
white = MAPSETUP_WARP_WINDOW[method] and WARP_LOAD_WHITE_FRAMES
or MAP_LOAD_WHITE_FRAMES,
}
return true
end
@@ -3553,6 +3614,8 @@ end
-- ramp with no load between the halves (home/map.asm:2281-2292).
function World:exitMenusFade(whiteFrames)
self.fade, self.fadeLevel = "white", 1
self.fadeWhiten = nil
self.fadeHold = whiteFrames or MENU_EXIT_WHITE_FRAMES
self.mapSetup = {
phase = "in", step = FADE_STEPS,
wait = whiteFrames or MENU_EXIT_WHITE_FRAMES,
@@ -3579,6 +3642,7 @@ World.FADE_RAMP = {
World.FADE_STEPS = FADE_STEPS
World.FADE_STEP_FRAMES = FADE_STEP_FRAMES
World.MAP_LOAD_WHITE_FRAMES = MAP_LOAD_WHITE_FRAMES
World.WARP_LOAD_WHITE_FRAMES = WARP_LOAD_WHITE_FRAMES
function World.fadeRampRow(fade, level)
local ramp = fade and World.FADE_RAMP[fade]
@@ -3597,8 +3661,26 @@ end
function World:battleReturnFade()
if self.mapSetup then return end
self.fade, self.fadeLevel = "white", 1
self.fadeWhiten = nil
self.fadeHold = WARP_LOAD_WHITE_FRAMES
self.mapSetup = { phase = "in", step = FADE_STEPS,
wait = MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES }
wait = WARP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES }
end
-- engine/tilesets/timeofday_pals.asm:160-187
function World:fadeBgSet()
local def = self.map and self.map.def
local bg = def and Palettes.bgSet(self.palettes, def, self.daytime)
if not (bg and self.fadeWhiten) then return bg end
local out = {}
for i, colors in ipairs(bg) do
if i >= 2 and i <= 7 and bg[1] and bg[1][1] then
out[i] = { bg[1][1], colors[2], colors[3], colors[4] }
else
out[i] = colors
end
end
return out
end
-- ---------------------------------------------------------------------------
@@ -3667,27 +3749,36 @@ end
function World:updateMapSetup()
local ms = self.mapSetup
if self.fadeHold then
self.fadeHold = self.fadeHold - 1
if self.fadeHold <= 0 then self.fadeHold = nil end
end
ms.wait = ms.wait - 1
if ms.wait > 0 then return end
ms.wait = FADE_STEP_FRAMES
if ms.phase == "out" then
ms.step = ms.step + 1
self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS
if ms.step >= FADE_STEPS then
-- setMap clears self.fade (a map load repaints everything), so the sheet
-- has to be re-armed at full strength on the far side for the fade in to
-- take back down.
ms.load()
ms.phase = "in"
self.fade, self.fadeLevel = "white", 1
-- engine/tilesets/timeofday_pals.asm:277-299
ms.wait = FADE_STEP_FRAMES + MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES
-- engine/tilesets/timeofday_pals.asm:122-128
if ms.step <= FADE_STEPS then
self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS
return
end
ms.load()
ms.phase = "in"
ms.step = FADE_STEPS
self.fade, self.fadeLevel = "white", 1
self.fadeWhiten = nil
local white = ms.white or MAP_LOAD_WHITE_FRAMES
-- home/lcd.asm:35-72
self.fadeHold = white
-- engine/tilesets/timeofday_pals.asm:277-299
ms.wait = white + FADE_STEP_FRAMES
return
end
ms.step = ms.step - 1
if ms.step <= 0 then
self.fade, self.fadeLevel = nil, nil
self.fadeHold, self.fadeWhiten = nil, nil
self.mapSetup = nil
-- `callasm FlyToAnim` is the command straight after `newloadmap
-- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605).
@@ -3696,6 +3787,10 @@ function World:updateMapSetup()
local function respawn() self.flyHidden = nil end
if not self:startFlyAnim("to", ms.flyIn, respawn) then respawn() end
end
-- engine/events/overworld.asm:869-870
if ms.digIn then self:digReturn() end
-- engine/overworld/events.asm:1008-1021
if ms.fallIn then self:startSkyfall() end
return
end
self.fadeLevel = ms.step / FADE_STEPS
@@ -4062,6 +4157,14 @@ function World:updateMovement()
st.sleep = duration
return
end
-- engine/overworld/movement.asm:142-160, map_objects.asm:1481-1493
if b == Movement.RETURN_DIG then
local duration = st.bytes[st.i] or 0
st.i = st.i + 1
if ent.scriptSpin then ent:scriptSpin(duration, true) end
st.sleep = duration
return
end
-- engine/overworld/movement.asm:163
if b == 0x57 then
local duration = st.bytes[st.i] or 0
@@ -4135,6 +4238,13 @@ function World:updateMovement()
-- (engine/overworld/map_objects.asm:284-294), so the object steps
-- without turning. ContinueReadingMovement again, so no frame here.
ent.fixedFacing = act.fixed
elseif act.kind == "visible" then
-- macros/scripts/movement.asm:99-106
if st.objectId == 0 then
self.playerHidden = (not act.on) or nil
else
ent.hiddenByMovement = (not act.on) or nil
end
elseif act.kind == "treeshake" then
-- Movement_tree_shake: 24 frames of STEP_TYPE_SLEEP with OBJECT_ACTION
-- set to OBJECT_ACTION_WEIRD_TREE (engine/overworld/movement.asm:334).
@@ -4764,20 +4874,32 @@ function World:escapeRopeTarget()
return backup.map, destWarp
end
-- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX.WARP_TO,
-- `loadvar VAR.MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP.DOOR` with
-- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land
-- the player on the destination warp's own tile. The dig-spin sprite work is
-- not ported, the same standing decision World:flyTo records for the two fly
-- animations.
-- .UsedDigScript -- engine/events/overworld.asm:851-872
-- engine/overworld/events.asm:1034
function World:runEscapeWarp(destMapId, destWarp)
self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO)
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
return self:runMapSetup(MAPSETUP.DOOR, function()
local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down")
if ok then self:spawnFacing() end
local function load()
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
local ok = self:runMapSetup(MAPSETUP.DOOR, function()
local loaded = self:setMap(destMapId, destWarp.x, destWarp.y, "down")
if loaded then self:spawnFacing() end
return loaded
end)
if self.mapSetup then
self.mapSetup.digIn = true
else
self:digReturn()
end
return ok
end)
end
self:beginMovement(0, Movement.digOutBytes(), load)
return true
end
-- The half after `newloadmap`: engine/events/overworld.asm:869-870
function World:digReturn()
self:playSfxNamed("Sfx_WarpFrom", SFX.WARP_FROM)
self:beginMovement(0, Movement.digReturnBytes())
end
-- EscapeRopeEffect (engine/items/item_effects.asm): EscapeRopeFunction, and
@@ -6073,7 +6195,7 @@ end
-- which is exactly what World:warpToSpawn resolves (blackoutmod override
-- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a
-- bike arrives on foot the way `loadvar VAR.MOVEMENT, PLAYER_NORMAL` leaves
-- it. The teleport spin, like the dig spin, is sprite work and not ported.
-- engine/events/overworld.asm:940-955
function World:runTeleport(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
@@ -6808,8 +6930,13 @@ function World:startBattle(opts, onDone)
self.wildCooldown = 5
self.battleActive = nil
game.stack:pop()
-- engine/overworld/scripting.asm:1178-1184, engine/events/whiteout.asm:1-21
local whiteout = outcome == "lose"
and opts.battleType ~= BATTLETYPE.CANLOSE
-- engine/overworld/events.asm:1158-1162, data/maps/setup_scripts.asm:124
if not self:scriptRunning() then self:battleReturnFade() end
if not whiteout and not self:scriptRunning() then
self:battleReturnFade()
end
-- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2.
-- The port never forfeits or draws a battle, so "lose" is the only
-- other outcome startBattle's onDone hands back; VAR.BATTLERESULT
@@ -6871,7 +6998,7 @@ function World:startBattle(opts, onDone)
-- .FinishRival is what heals the party, not a whiteout. Warping here
-- moved the loser to the spawn point and then ran that walk-off over
-- whatever stood there.
if outcome == "lose" and opts.battleType ~= BATTLETYPE.CANLOSE then
if whiteout then
self:healParty()
if not BugContest.isActive(game.save) then
CallAsm.run(self, "HalveMoney")
@@ -6884,7 +7011,11 @@ function World:startBattle(opts, onDone)
Runtime.emit("world.blacked_out",
{ save = game.save, healTarget = self:healPoint() })
end
self:warpToSpawn()
-- engine/events/whiteout.asm:19-20
self:runMapSetup(MAPSETUP.WARP, function()
self:warpToSpawn()
return true
end)
end
-- RestartMapMusic: the map theme comes back with the overworld, over
-- whatever the battle left playing (the victory jingle loops until
@@ -8382,6 +8513,9 @@ function World:interactBody()
if self:busy() or not self.player or not self.vm then return false end
local p = self.player
if p.moving then return false end
-- engine/overworld/events.asm:505-510
if p.stopForEvent then p:stopForEvent() end
self.turningDirection = nil
local d = Map.DELTA[p.facing]
local fx, fy = p.cellX + d[1], p.cellY + d[2]
local npc = self:npcAt(self:facingObjectCell())
@@ -9736,7 +9870,10 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- over from the script that warped cannot survive it -- LoadMapPalettes and
-- DeleteMapObject are what end both on the cart.
self.fade = nil
self.fadeHold, self.fadeWhiten = nil, nil
self.shake = nil
self.skyfall = nil
if self.player then self.player.spriteYOffset = nil end
self.map = Map.new(def, tileset)
self:rebuildAttrGrid()
-- A follow pairing points at two live objects, and a map load rebuilds them
@@ -9996,7 +10133,11 @@ function World:takeWarp(warpDef)
return false
end
end
self:warpSound()
-- loads under MAPSETUP_FALL -- engine/overworld/events.asm:349-353
local p = self.player
local falling = p and self.map
and isPitCollision(self.map:cellCollision(p.cellX, p.cellY)) or false
if not falling then self:warpSound() end
-- wBackupMapGroup / wBackupMapNumber: the map being LEFT. The elevator's
-- .FindCurrentFloor is the only thing that reads it, and it is what makes
-- "Now on:" say the floor you got in from.
@@ -10012,15 +10153,20 @@ function World:takeWarp(warpDef)
Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId,
x = destX, y = destY, warp = warpDef,
toWarp = destWarpNumber })
return self:runMapSetup(MAPSETUP.DOOR, function()
local ok = self:setMap(destMapId, destX, destY,
(self.player and self.player.facing) or "down")
if ok then
self:spawnFacing()
self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId)
end
return ok
end)
local taken = self:runMapSetup(falling and MAPSETUP.FALL or MAPSETUP.DOOR,
function()
local ok = self:setMap(destMapId, destX, destY,
(self.player and self.player.facing) or "down")
if ok then
self:spawnFacing()
self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId)
end
return ok
end)
if falling then
if self.mapSetup then self.mapSetup.fallIn = true else self:startSkyfall() end
end
return taken
end
-- RefreshPlayerSprite (engine/overworld/map_objects.asm) is the whole rule for
@@ -10582,7 +10728,11 @@ function World:whiteOut()
Runtime.emit("world.blacked_out",
{ save = self.game and self.game.save, healTarget = self:healPoint() })
end
self:warpToSpawn()
-- engine/events/whiteout.asm:19-20
self:runMapSetup(MAPSETUP.WARP, function()
self:warpToSpawn()
return true
end)
end)
end
@@ -10799,6 +10949,7 @@ function World:stepBody()
-- parked on the earthquake's own waitFrames while the screen is still
-- rattling), so both tick above the busy() gate rather than below it.
if self.shake then self:updateShake() end
if self.skyfall then self:updateSkyfall() end
-- The map setup chain ticks above the busy() gate for the same reason: it IS
-- what closes that gate, so nothing below can be allowed to advance it.
if self.mapSetup then
@@ -10905,6 +11056,9 @@ function World:stepBody()
self.player.inGrass =
self:grassAt(self.player.cellX, self.player.cellY)
end
elseif self.player and self.player.stopForEvent then
-- engine/overworld/map_objects.asm:1851-1860
self.player:stopForEvent()
end
self:updatePeople()
return
@@ -11124,11 +11278,11 @@ function World:drawPeople(s, billboard)
local filter = self.spriteFilter
local drawList = {}
if not hideAll then
if not hidePlayer and not self.playerMasked then
if not hidePlayer and not self.playerMasked and not self.playerHidden then
drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 }
end
for _, npc in ipairs(self.npcs) do
if not filter or filter(npc) then
if (not filter or filter(npc)) and not npc.hiddenByMovement then
drawList[#drawList + 1] = {
kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py,
}
@@ -11390,11 +11544,12 @@ function World:drawFadeRemap(s, w, h)
local def = self.map and self.map.def
if not (def and self.palettes) then return false end
local cache = self.fadeRemapCache
local whiten = self.fadeWhiten or false
if not (cache and cache.byte == byte and cache.def == def
and cache.daytime == self.daytime and cache.palettes == self.palettes
and cache.mode == GbcPalette.mode
and cache.mode == GbcPalette.mode and cache.whiten == whiten
and cache.custom == GbcPalette.customRamp) then
local bg = Palettes.bgSet(self.palettes, def, self.daytime)
local bg = self:fadeBgSet()
if not bg then return false end
-- home/fade.asm:32-38
local pals = {}
@@ -11406,7 +11561,7 @@ function World:drawFadeRemap(s, w, h)
local uniforms = GbcPalette.remapUniforms(pals, byte)
if not uniforms then return false end
cache = { byte = byte, def = def, daytime = self.daytime,
palettes = self.palettes, mode = GbcPalette.mode,
palettes = self.palettes, mode = GbcPalette.mode, whiten = whiten,
custom = GbcPalette.customRamp, uniforms = uniforms }
self.fadeRemapCache = cache
end
@@ -11512,7 +11667,8 @@ function World:draw()
elseif tilt then
self:drawTilted(w, h, s, gw, gh)
else
if self.fade and (self.fadeLevel or 1) < 1 then
-- engine/tilesets/timeofday_pals.asm:65-91, home/lcd.asm:35-72
if self.fade and not self.fadeHold then
fadeStepped = self:drawFadeRemap(s, w, h)
end
if not fadeStepped then self:drawWorldBody(s) end
@@ -0,0 +1,99 @@
-- (EnemySendOutFirstMon, engine/battle/core.asm:1432-1435)
-- WaitForSoundToFinish, home/pokemon.asm:145-149
-- POKEPORT_DRIVER=tests/drivers/battle_hud_after_cry_bug2118_test.lua POKEPORT_IDENTITY=bug2118 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
local LOG = DIR .. "/bug2118.log"
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
local function note(...)
U.log(...)
local f = io.open(LOG, "a")
if not f then return end
f:write(table.concat({ ... }, " "), "\n")
f:close()
end
local function check(label, ok)
note(ok and "PASS" or "FAIL", label)
return ok
end
local function hudStrings(battle)
local strings = {}
local realFont, realDraw = Font.draw, love.graphics.draw
Font.draw = function(text) strings[#strings + 1] = tostring(text) end
love.graphics.draw = function() end
pcall(battle.drawHUDs, battle, 0)
Font.draw, love.graphics.draw = realFont, realDraw
return strings
end
local function drew(strings, want)
for _, s in ipairs(strings) do
if s:find(want, 1, true) then return true end
end
return false
end
game.save.party = {
Pokemon.new(game.data, "BULBASAUR", 12),
Pokemon.new(game.data, "PIDGEY", 9),
}
U.teleport(game, "ROUTE_3", 13, 6, "left")
U.wait(10)
local ow = game.overworld
check("the overworld is up", ow ~= nil)
local battle = BattleState.newTrainer(game, "OPP_BUG_CATCHER", 4)
battle.onFinish = function() end
if ow then ow:pushBattle(battle) end
for _ = 1, 600 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the trainer battle reached the screen", game.stack:top() == battle)
local grew = false
for _ = 1, 900 do
if battle.growIn then grew = true break end
U.tap(game, "a")
U.wait(4)
end
check("AnimateSendingOutMon started", grew)
for _ = 1, 240 do
if not battle.growIn then break end
U.wait(1)
end
check("the grow-in finished", battle.growIn == nil)
check("the cry is still outstanding (enemyHudPending)",
battle.enemyHudPending == true)
check("the enemy name is NOT on screen yet",
not drew(hudStrings(battle), battle.enemy.name))
U.shot(game, DIR .. "/bug2118_1_sprite_no_hud.png")
for _ = 1, 900 do
if not battle.enemyHudPending then break end
U.wait(1)
end
check("the cry finished and the HUD was released",
battle.enemyHudPending == nil)
U.wait(2)
check("the enemy name and HP bar are on screen now",
drew(hudStrings(battle), battle.enemy.name))
U.shot(game, DIR .. "/bug2118_2_hud_after_cry.png")
U.log("Correct: the foe's pic grows out of its ball with the top-left of the")
U.log("screen still blank, the cry plays in full, and only then do the name,")
U.log("<LV> and HP bar appear together (#2118).")
U.log("Screenshots: " .. DIR .. "/bug2118_*.png")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,71 @@
-- (engine/overworld/movement.asm:151)
-- SHOT_DIR=/tmp/shots POKEPORT_VERSION=red \
-- POKEPORT_DRIVER=tests/drivers/bill_face_rotation_bug2117_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
local MAP, STAND = "BILLS_HOUSE", { x = 1, y = 5, facing = "up" }
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.player.name = "bryan"
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
game.save.flags.EVENT_MET_BILL = nil
game.save.flags.EVENT_MET_BILL_2 = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not check("Bill's House loaded", ow ~= nil and ow.map
and ow.map.id == MAP) then
idle()
end
local monster
for _, npc in ipairs(ow.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_MONSTER" then monster = npc end
end
if not check("the monster form is on the map", monster ~= nil) then idle() end
check("it is a STAY, NONE object",
monster.def.movement == "STAY" and monster.def.range == "NONE")
check("it rolls a facing but never steps",
monster.wanders == true and monster.steps == false)
local seen, order = {}, {}
local startX, startY = monster.cellX, monster.cellY
local moved = false
for frame = 0, 600 do
if not seen[monster.facing] then
seen[monster.facing] = true
order[#order + 1] = monster.facing
end
if monster.cellX ~= startX or monster.cellY ~= startY then moved = true end
if frame % 60 == 0 then
U.shot(game, SHOT_DIR .. "/bill_face_" .. frame .. ".png")
end
U.wait(1)
end
check("Bill takes more than one facing over 600 frames: "
.. table.concat(order, ","), #order >= 2)
check("and never leaves his cell", not moved)
U.log("shots in", SHOT_DIR)
idle()
end
@@ -0,0 +1,131 @@
-- (bills_pc.asm:176)
-- "<PK><MN> BOX." (data/text/text_3.asm:30)
-- with balls.2bpp tile 0 (bills_pc.asm:118)
-- SHOT_DIR=/tmp/bug2124 POKEPORT_DRIVER=tests/drivers/bills_pc_changebox_bug2124.lua \
-- POKEPORT_IDENTITY=<a v11 cache> POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
local Theme = require("src.ui.Theme")
local Font = require("src.render.Font")
local Pokemon = require("src.pokemon.Pokemon")
local pass = true
local function check(msg, cond)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function capture()
local drawn = {}
local realCode, realDraw = Font.drawCode, Font.draw
local realImage = love.graphics.draw
Font.drawCode = function(code, x, y)
drawn[#drawn + 1] = { kind = "code", code = code, x = x, y = y }
return realCode(code, x, y)
end
Font.draw = function(s, x, y, ...)
drawn[#drawn + 1] = { kind = "text", text = s, x = x, y = y }
return realDraw(s, x, y, ...)
end
love.graphics.draw = function(img, ...)
drawn[#drawn + 1] = { kind = "image",
path = type(img) == "userdata" and "?" or (type(img) == "table" and img.path) }
return realImage(img, ...)
end
game.stack:draw()
Font.drawCode, Font.draw, love.graphics.draw = realCode, realDraw, realImage
return drawn
end
local function sawCode(drawn, code)
for _, d in ipairs(drawn) do
if d.kind == "code" and d.code == code then return d end
end
end
local function sawText(drawn, want)
for _, d in ipairs(drawn) do
if d.kind == "text" and d.text == want then return d end
end
end
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
U.wait(6)
local boxes = Boxes.ensure(game.save)
boxes[1] = { Pokemon.new(game.data, "PIDGEY", 12) }
boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) }
game.save.currentBox = 1
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) }
game.stack:push(BoxMenu.new(game))
U.wait(4)
local bills = game.stack:top()
check("Bill's PC menu is up", getmetatable(bills) == Menu)
local changeIdx
for i, item in ipairs(bills.items) do
if tostring(item.label):find("CHANGE BOX", 1, true) then changeIdx = i end
end
check("CHANGE BOX is on the menu", changeIdx ~= nil)
for _ = 2, changeIdx or 1 do U.tap(game, "down"); U.wait(2) end
U.tap(game, "a")
U.wait(20)
check("CHANGE BOX asks first", getmetatable(game.stack:top()) == TextBox)
check("the parent cursor is marked hollow under the ask",
bills.hollowIndex == bills.index)
do
local drawn = capture()
check("and the unfilled arrow is what gets drawn",
sawCode(drawn, Theme.cursorHollow) ~= nil
and sawCode(drawn, Theme.cursor) == nil)
end
U.shot(game, DIR .. "/bug2124_1_hollow_cursor.png")
local function isPicker(s)
return getmetatable(s) == Menu and s.kind == "pc_box_change"
end
for _ = 1, 60 do
if isPicker(game.stack:top()) then break end
U.tap(game, "a")
U.wait(20)
end
check("YES opens the box picker", isPicker(game.stack:top()))
U.wait(4)
do
local drawn = capture()
check("the prompt is the two-tile <PK><MN>, not the word POKeMON",
sawText(drawn, "<PK><MN> BOX.") ~= nil
and sawText(drawn, "POKéMON BOX.") == nil)
check("no {DONE} marker leaks onto the screen",
sawText(drawn, "<PK><MN> BOX.{DONE}") == nil)
local balls = 0
for _, d in ipairs(drawn) do
if d.kind == "image" then balls = balls + 1 end
end
check("the box markers are drawn tiles, not vectors", balls >= 2)
end
U.shot(game, DIR .. "/bug2124_2_change_box_list.png")
U.tap(game, "b")
U.wait(12)
check("B returns to Bill's PC menu", game.stack:top() == bills)
check("and the cursor is filled again", bills.hollowIndex == nil)
do
local drawn = capture()
check("the filled arrow is back", sawCode(drawn, Theme.cursor) ~= nil)
end
U.shot(game, DIR .. "/bug2124_3_back_on_pc_menu.png")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
U.log("Look at bug2124_1_hollow_cursor.png: the arrow beside CHANGE BOX is")
U.log("the unfilled outline, not the solid triangle. In")
U.log("bug2124_2_change_box_list.png the second prompt line reads with the")
U.log("two-tile PK MN ligature and the markers beside BOX 1 and BOX 3 are")
U.log("the cart's flat pokeball ring -- no white belt, no centre pip -- and")
U.log("pick up colour in ADVANCED mode.")
U.log("Shots are in " .. DIR)
end
@@ -0,0 +1,101 @@
-- engine/pikachu/pikachu_movement.asm:88
-- EmotionBubble (engine/overworld/emotion_bubbles.asm:60)
-- (engine/pikachu/pikachu_emotions.asm:14)
-- BillsHouseScript0 actually runs. No POKEPORT_SPEED: it scales the logic
-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_bug2165_test.lua POKEPORT_IDENTITY=bug2165 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Re-run with POKEPORT_VERSION=yellow.")
idle()
end
local MAP, STAND = "BILLS_HOUSE", { x = 3, y = 6, facing = "up" }
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.player.name = "bryan"
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
game.save.flags.EVENT_MET_BILL = nil
game.save.flags.EVENT_MET_BILL_2 = nil
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
game.save.pikachuMapScriptActive = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not check("Bill's House loaded", ow ~= nil and ow.map
and ow.map.id == MAP) then
idle()
end
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local npc = follower()
if not check("the follower spawned", npc ~= nil) then idle() end
check("the confused beat started", ow.pikachuBillsScene == true)
check("the follower never rolls its own facing (#2117 pin)",
npc.wanders == false)
check("the scripted walk uses the 16-frame Pikachu step",
npc.stepFrames == 16)
for frame = 0, 64, 16 do
U.shot(game, SHOT_DIR .. "/bug2165_walk_" .. frame .. ".png")
U.wait(15)
end
local sawBubble = false
for _ = 1, 240 do
if ow.emote and ow.emote.bubble and not ow.emote.pikaPic then
sawBubble = true
break
end
U.wait(1)
end
check("the silent 60-frame question bubble comes up first", sawBubble)
if sawBubble then
U.shot(game, SHOT_DIR .. "/bug2165_bubble.png")
end
local sawPic = false
for _ = 1, 240 do
if ow.emote and ow.emote.pikaPic then sawPic = true break end
U.wait(1)
end
check("the emotion animation follows it", sawPic)
if sawPic then
check("and it is the emotion's own pikapic, not the battle front pic",
ow.emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
check("a scripted emotion cannot be mashed away",
ow.emote.skippable ~= true)
U.shot(game, SHOT_DIR .. "/bug2165_pikapic.png")
end
U.log("listen for PikachuCry19 under the box; shots in", SHOT_DIR)
idle()
end
+148
View File
@@ -0,0 +1,148 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local PHASE = os.getenv("PHASE") or "all"
local TICKS = tonumber(os.getenv("TICKS") or "") or 22
local function idle()
while true do coroutine.yield() end
end
if not GameVersion.isYellow() then
U.log("FAIL not the yellow cache")
idle()
end
local function stage()
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.player.name = "bryan"
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = nil
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
game.save.pikachuMapScriptActive = nil
end
local function findBill(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "BILLSHOUSE_BILL_POKEMON" then return n end
end
return nil
end
local function findFollower(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local function inList(list, e)
for _, v in ipairs(list or {}) do if v == e then return true end end
return false
end
local overlap = 0
local function dump(tag, ow)
local pk = findFollower(ow)
local bill = findBill(ow)
local p = ow.player
if pk and bill and pk.cellX == bill.cellX and pk.cellY == bill.cellY then
overlap = overlap + 1
end
U.log(string.format(
"%s f=%d P=(%d,%d)%s pika=%s bill=%s emote=%s",
tag, U.frame(), p.cellX, p.cellY, p.facing,
pk and string.format("(%d,%d)px%d,%d mv=%s pass=%s npcs=%s ents=%s",
pk.cellX, pk.cellY, pk.px, pk.py, tostring(pk.moving),
tostring(pk.passable), tostring(inList(ow.npcs, pk)),
tostring(inList(ow.entities, pk))) or "GONE",
bill and string.format("(%d,%d)px%d,%d", bill.cellX, bill.cellY,
bill.px, bill.py) or "HIDDEN",
ow.emote and "yes" or "no"))
end
-- a scripted-walk run from an arbitrary start cell
local function run(label, sx, sy, ticks)
stage()
U.teleport(game, "BILLS_HOUSE", sx, sy, "up")
U.wait(4)
local ow = game.overworld
overlap = 0
U.log("=== " .. label .. " start=(" .. sx .. "," .. sy .. ") scene="
.. tostring(ow.pikachuBillsScene) .. " ===")
local shot = false
for _ = 1, ticks do
dump(label, ow)
if overlap > 0 and not shot then
shot = true
U.log("!!! " .. label .. " OVERLAP pikachu on Bill's cell")
U.shot(game, SHOT_DIR .. "/" .. label .. "_overlap.png")
end
U.wait(4)
end
U.shot(game, SHOT_DIR .. "/" .. label .. "_end.png")
U.log("=== " .. label .. " overlapTicks=" .. overlap .. " ===")
return ow
end
if PHASE == "all" or PHASE == "A" then
run("A_door37", 3, 7, TICKS)
run("A_door27", 2, 7, TICKS)
run("B_mid36", 3, 6, TICKS)
end
if PHASE == "all" or PHASE == "D" then
-- the real user path: walk in the door, let the confused beat settle,
-- then walk up column 6 onto the parked follower at (6,6) -- the only
-- southern approach to Bill at (6,5).
stage()
U.teleport(game, "BILLS_HOUSE", 3, 7, "up")
local ow = game.overworld
for _ = 1, 600 do
if ow.emote == nil and #(ow.scriptMoves or {}) == 0 then break end
U.wait(1)
end
U.wait(20)
overlap = 0
dump("D_settled", ow)
U.shot(game, SHOT_DIR .. "/D_settled.png")
local function step(dir)
local sx, sy = ow.player.cellX, ow.player.cellY
for _ = 1, 90 do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
if (ow.player.cellX ~= sx or ow.player.cellY ~= sy)
and not ow.player.moving then break end
end
game.input.state[dir] = false
U.wait(4)
dump("D_step_" .. dir, ow)
end
step("right"); step("right"); step("right")
step("up")
U.shot(game, SHOT_DIR .. "/D_on_follower.png")
local pk = findFollower(ow)
U.log("D player=(" .. ow.player.cellX .. "," .. ow.player.cellY
.. ") pika=" .. (pk and ("(" .. pk.cellX .. "," .. pk.cellY .. ")")
or "GONE"))
if pk and pk.cellX == ow.player.cellX and pk.cellY == ow.player.cellY then
U.log("!!! D PLAYER STANDS ON THE FOLLOWER (passable, same cell)")
end
end
U.log("DONE")
U.wait(2)
love.event.quit()
idle()
end
+279
View File
@@ -0,0 +1,279 @@
-- #2165 refix 2: home/overworld.asm:1234-1252 (the B bypass and the
-- wPikachuCollisionCounter soft bump), engine/pikachu/pikachu_follow.asm:59
-- (the companion never starts the confused walk on the player's cell) and
-- engine/events/hidden_events/bills_house_pc.asm:14-40 (268 delay frames plus
-- five WaitForSoundToFinish drains). Self-terminating: it quits LOVE with 0
-- on all-PASS and 1 otherwise. No POKEPORT_SPEED -- it scales the logic
-- clock only and desyncs the cries against the walk.
--
-- SHOT_DIR=/tmp/shots2165b POKEPORT_SHOT_DIR=/tmp/shots2165b \
-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_refix2_2165.lua \
-- POKEPORT_IDENTITY=yellow-sep04 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots2165b"
local failures = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
U.log(failures == 0 and "DONE all checks passed"
or ("DONE " .. failures .. " check(s) failed"))
U.log("shots in", SHOT_DIR)
love.event.quit(failures == 0 and 0 or 1)
while true do coroutine.yield() end
end
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
finish()
end
local MAP = "BILLS_HOUSE"
local function resetSave()
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.player.name = "bryan"
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
game.save.flags.EVENT_MET_BILL = nil
game.save.flags.EVENT_MET_BILL_2 = nil
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
game.save.pikachuMapScriptActive = nil
end
local ow
local function follower()
for _, n in ipairs(ow and ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local function objectAt(name)
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
local function press(dir, frames)
for _ = 1, frames do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
end
local function release(...)
for _, b in ipairs({ ... }) do game.input.state[b] = false end
U.wait(1)
end
-- walk the player exactly one cell: hold until the cell index changes,
-- release, then let the in-flight step land
local function step(dir)
local p0 = ow.player
local sx, sy = p0.cellX, p0.cellY
for _ = 1, 60 do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
if p0.cellX ~= sx or p0.cellY ~= sy then break end
end
game.input.state[dir] = false
for _ = 1, 40 do
if not p0.moving then break end
coroutine.yield()
end
U.wait(2)
end
-- =====================================================================
-- A. the soft bump and the B bypass, both of which only exist while
-- following is ENABLED -- home/overworld.asm:1240-1252
-- =====================================================================
resetSave()
game.save.flags.EVENT_MET_BILL_2 = true -- BillsHouseScript0 stays asleep
U.teleport(game, MAP, 3, 6, "up")
U.wait(30)
ow = game.overworld
if not check("Bill's House loaded", ow and ow.map and ow.map.id == MAP) then
finish()
end
check("the confused beat did not run with EVENT_MET_BILL_2 set",
ow.pikachuBillsScene ~= true)
-- take a step so the companion trails onto the cell behind the player
step("up")
U.wait(20)
local npc = follower()
if not check("the follower spawned and trailed", npc ~= nil) then finish() end
check("a following companion is walk-through when nothing is pressed",
npc.passable == true and (ow.pikachuCollisionCounter or 0) == 0)
local p = ow.player
local backDir = (npc.cellY > p.cellY and "down")
or (npc.cellY < p.cellY and "up")
or (npc.cellX > p.cellX and "right") or "left"
U.log(string.format("A player=(%d,%d)%s pika=(%d,%d) push=%s",
p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY, backDir))
local startX, startY = p.cellX, p.cellY
local seeded, blockedTicks, movedAt = 0, 0, nil
for i = 1, 40 do
table.insert(game.input.pressQueue, backDir)
game.input.state[backDir] = true
coroutine.yield()
seeded = math.max(seeded, ow.pikachuCollisionCounter or 0)
if p.cellX ~= startX or p.cellY ~= startY or p.moving then
movedAt = i
break
end
if p.facing == backDir and npc.passable == false then
blockedTicks = blockedTicks + 1
end
end
release(backDir)
U.log("A seeded=" .. seeded .. " blockedTicks=" .. blockedTicks
.. " movedAt=" .. tostring(movedAt))
check("turning toward the companion seeds the 8-count bump", seeded == 8)
check("the bump holds the player for several frames, then yields",
blockedTicks >= 4 and movedAt ~= nil)
U.wait(20)
npc = follower()
p = ow.player
backDir = (npc.cellY > p.cellY and "down") or (npc.cellY < p.cellY and "up")
or (npc.cellX > p.cellX and "right") or "left"
startX, startY = p.cellX, p.cellY
local bWalkedThrough = false
game.input.state.b = true
for _ = 1, 40 do
table.insert(game.input.pressQueue, "b")
table.insert(game.input.pressQueue, backDir)
game.input.state[backDir] = true
coroutine.yield()
if p.cellX ~= startX or p.cellY ~= startY or p.moving then
bWalkedThrough = true
break
end
end
release(backDir, "b")
check("holding B walks straight through the companion", bWalkedThrough)
-- =====================================================================
-- B. the confused walk lands directly below Bill
-- (scripts/BillsHouse_2.asm:125 from the emerged cell)
-- =====================================================================
resetSave()
U.teleport(game, MAP, 2, 7, "up")
U.wait(20)
ow = game.overworld
npc = follower()
if not check("the follower spawned for the confused beat", npc ~= nil) then
finish()
end
check("the confused beat started", ow.pikachuBillsScene == true)
check("the scripted walk uses the 16-frame Pikachu step",
npc.stepFrames == 16)
for _ = 1, 600 do
if ow.emote and ow.emote.pikaPic then break end
U.wait(1)
end
for _ = 1, 900 do
if not ow.emote then break end
U.wait(1)
end
U.wait(10)
local bill = objectAt("BILLSHOUSE_BILL_POKEMON")
npc = follower()
U.log(string.format("B pika=(%d,%d) bill=(%s,%s)",
npc.cellX, npc.cellY,
bill and bill.cellX or "?", bill and bill.cellY or "?"))
check("the confused walk ends directly below Bill (6,6)",
npc.cellX == 6 and npc.cellY == 6)
check("which is Bill's own column, one row south",
bill ~= nil and npc.cellX == bill.cellX and npc.cellY == bill.cellY + 1)
U.shot(game, SHOT_DIR .. "/01_confused_walk_ends_below_bill.png")
-- =====================================================================
-- C. the parked companion is solid -- home/overworld.asm:1238-1240
-- =====================================================================
p = ow.player
for _ = 1, 4 do step("right") end
U.wait(10)
U.log(string.format("C player=(%d,%d)%s pika=(%d,%d) passable=%s",
p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY,
tostring(npc.passable)))
check("the player reached the cell south of the companion",
p.cellX == 6 and p.cellY == 7)
check("and following is still disabled, so the companion is solid",
npc.passable == false)
startX, startY = p.cellX, p.cellY
press("up", 60)
release("up")
check("pushing north into the parked companion never moves the player",
p.cellX == startX and p.cellY == startY)
U.shot(game, SHOT_DIR .. "/02_solid_companion_blocks_player.png")
-- =====================================================================
-- D. the cell separator runs the cartridge's 268 delay frames plus five
-- WaitForSoundToFinish drains -- bills_house_pc.asm:14-40, :51-63
-- =====================================================================
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
U.teleport(game, MAP, 1, 5, "up")
U.wait(20)
ow = game.overworld
U.tap(game, "a")
U.wait(10)
for _ = 1, 200 do -- type + prompt-button the initiated text away
U.tap(game, "a")
U.wait(2)
if ow.runner and ow.runner:isRunning() then break end
if #(ow.pendingScripts or {}) > 0 then break end
end
local chainFrames, shot = 0, false
for _ = 1, 1200 do
if game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then break end
chainFrames = chainFrames + 1
U.wait(1)
if not shot and chainFrames == 150 then
shot = true
U.shot(game, SHOT_DIR .. "/03_cell_separator_running.png")
end
end
-- 268 asm delay frames less the handful the text-box drain eats before
-- this loop starts; the pre-fix chain was 222 and measures ~218 here
U.log("D chainFrames=" .. chainFrames .. " (asm budget 268 + five SFX)")
check("the separator chain runs the cartridge's ~268 delay frames",
chainFrames >= 255)
check("and it sets EVENT_USED_CELL_SEPARATOR_ON_BILL only at the end",
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL == true)
for _ = 1, 600 do
if objectAt("BILLSHOUSE_BILL1") then break end
U.wait(1)
end
U.wait(30)
check("Bill steps out of the machine when the chain ends",
objectAt("BILLSHOUSE_BILL1") ~= nil)
U.shot(game, SHOT_DIR .. "/04_bill_out_of_the_machine.png")
finish()
end
+115 -10
View File
@@ -1,8 +1,11 @@
-- ../pokecrystal/data/maps/setup_scripts.asm:127-140
-- ../pokecrystal/engine/overworld/map_setup.asm:181-189
-- ../pokecrystal/home/fade.asm:35-62
-- ../pokecrystal/engine/tilesets/timeofday_pals.asm:160-187
-- ../pokecrystal/engine/events/whiteout.asm:1-21
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-exit-fade-2141"
@@ -24,6 +27,12 @@ return function(game)
assert(world and world.map, "the crystal world did not boot")
local save, data = game.save, game.data
save.party = { Mon.new(data, "CYNDAQUIL", 12) }
-- home/time.asm GetTimeOfDay, engine/tilesets/timeofday_pals.asm:160-187
local night = os.getenv("POKEPORT_FADE_NIGHT") == "1"
if night then
world.clockHour = 23
world:applyPalettes()
end
world.mapScenes = world.mapScenes or {}
world.mapScenes.NEW_BARK_TOWN = 1
@@ -37,13 +46,16 @@ return function(game)
U.hold(game, "up", 24)
local whiteTicks, doorTicks, entered = 0, 0, false
local doorSheet, doorWhiten = 0, 0
local lastDoorLevel
for _ = 1, 120 do
for _ = 1, 200 do
U.wait(1)
if world.mapSetup then
doorTicks = doorTicks + 1
if world.fadeLevel == 1 then whiteTicks = whiteTicks + 1 end
if world.fadeLevel ~= lastDoorLevel then
if world.fadeHold then doorSheet = doorSheet + 1 end
if world.fadeWhiten then doorWhiten = doorWhiten + 1 end
if world.fadeLevel ~= lastDoorLevel or world.fadeHold then
lastDoorLevel = world.fadeLevel
shot("door_in")
end
@@ -53,10 +65,19 @@ return function(game)
break
end
end
local doorHold = World.MAP_LOAD_WHITE_FRAMES
ok(entered, "the door warp finished")
ok(whiteTicks == 2 + 4 + 2,
("door: white held %d ticks (want 8: last out row, load, first in row)"):format(whiteTicks))
ok(doorTicks >= 20, ("door: the chain ran %d ticks"):format(doorTicks))
ok(whiteTicks == 2 + doorHold + 2,
("door: level 1 held %d ticks (want %d: last out row, load, first in row)")
:format(whiteTicks, 4 + doorHold))
ok(doorSheet == doorHold,
("door: pure white held %d ticks (want %d, the LCD-off window alone)")
:format(doorSheet, doorHold))
ok(doorWhiten == 10,
("door: FillWhiteBGColor held %d ticks over the way out (want 10)")
:format(doorWhiten))
ok(doorTicks == 10 + doorHold + 8,
("door: the chain ran %d ticks (want %d)"):format(doorTicks, 18 + doorHold))
local wild = Mon.new(data, "SENTRET", 3)
ok(world:startBattle({ wild = wild }), "a wild battle starts")
@@ -81,7 +102,7 @@ return function(game)
U.tap(game, "a")
local fadeTicks, rows, popped = 0, {}, false
local returnWhite, returnTicks = 0, 0
local returnWhite, returnTicks, returnSheet = 0, 0, 0
local lastReturnLevel
for _ = 1, 900 do
if game.stack:top() == screen then
@@ -100,7 +121,8 @@ return function(game)
if world.mapSetup then
returnTicks = returnTicks + 1
if world.fadeLevel == 1 then returnWhite = returnWhite + 1 end
if world.fadeLevel ~= lastReturnLevel then
if world.fadeHold then returnSheet = returnSheet + 1 end
if world.fadeLevel ~= lastReturnLevel or world.fadeHold then
lastReturnLevel = world.fadeLevel
shot("battle_return")
end
@@ -116,13 +138,96 @@ return function(game)
for _, row in ipairs(rows) do rowText[#rowText + 1] = ("%02x"):format(row) end
ok(table.concat(rowText, ",") == "90,40,00",
"rows 2100 / 1000 / 0000 in order (" .. table.concat(rowText, ",") .. ")")
ok(returnWhite == 4 + 2,
("return: white held %d ticks (want 6: load, first in row)"):format(returnWhite))
ok(returnTicks == 12, ("return: the fade in ran %d ticks (want 12)"):format(returnTicks))
local warpHold = World.WARP_LOAD_WHITE_FRAMES
ok(returnWhite == warpHold + 2,
("return: level 1 held %d ticks (want %d: load, first in row)")
:format(returnWhite, warpHold + 2))
ok(returnSheet == warpHold,
("return: pure white held %d ticks (want %d)"):format(returnSheet, warpHold))
ok(returnTicks == warpHold + 2 + 6,
("return: the fade in ran %d ticks (want %d)")
:format(returnTicks, warpHold + 8))
ok(world.mapSetup == nil and world.fade == nil, "the return fade cleared")
U.wait(5)
shot("done")
-- (engine/overworld/scripting.asm:1174-1184, engine/events/whiteout.asm:19)
local lossMap = world.map.id
save.party = { Mon.new(data, "CYNDAQUIL", 2) }
save.party[1].hp = 1
local loser = Mon.new(data, "SENTRET", 20)
ok(world:startBattle({ wild = loser }), "a losing battle starts")
local lossScreen
for _ = 1, 600 do
U.wait(1)
local top = game.stack:top()
if top and top.battle then lossScreen = top break end
end
ok(lossScreen ~= nil, "the losing battle screen is up")
local lossWhite, lossSheet, lossPopped = 0, 0, false
local lossPhase, lossFade, lossHold
for _ = 1, 1800 do
if game.stack:top() == lossScreen then
U.tap(game, "a")
else
if not lossPopped then
lossPopped = true
lossPhase = world.mapSetup and world.mapSetup.phase
lossFade, lossHold = world.fade, world.fadeHold
end
if world.mapSetup then
if world.fadeLevel == 1 then lossWhite = lossWhite + 1 end
if world.fadeHold then lossSheet = lossSheet + 1 end
shot("whiteout_in")
elseif lossWhite > 0 then
break
end
end
U.wait(1)
end
ok(lossPopped, "the lost battle popped")
ok(lossFade == "white" and lossPhase == "in",
("whiteout: the spawn map fades in (fade=%s phase=%s)")
:format(tostring(lossFade), tostring(lossPhase)))
ok(lossHold == warpHold,
("whiteout: pure white armed for %s ticks (want %d)")
:format(tostring(lossHold), warpHold))
ok(lossSheet == warpHold and lossWhite == warpHold + 2,
("whiteout: held %d pure / %d level-1 ticks"):format(lossSheet, lossWhite))
ok(world.map.id ~= lossMap, "and the player is at the spawn point")
U.wait(5)
shot("whiteout_done")
-- engine/tilesets/timeofday_pals.asm:160-187 on a PALETTE_AUTO map
if night then
world:warpToMapId("NEW_BARK_TOWN", 13, 7, "up")
for _ = 1, 200 do
if not world:busy() then break end
U.wait(1)
end
U.wait(10)
ok(world.map.id == "NEW_BARK_TOWN" and world.daytime == "NITE",
("outdoors at night (map=%s daytime=%s)")
:format(tostring(world.map.id), tostring(world.daytime)))
world:battleReturnFade()
local nightHold, nightPlane = 0, 0
for _ = 1, 200 do
if not world.mapSetup then break end
if world.fadeHold then
nightHold = nightHold + 1
else
nightPlane = nightPlane + 1
shot("night_outdoor_in")
end
U.wait(1)
end
ok(nightHold == warpHold,
("night: pure white held %d ticks (want %d)"):format(nightHold, warpHold))
ok(nightPlane == World.FADE_STEPS * World.FADE_STEP_FRAMES,
("night: %d ramp ticks through the palette remap (want %d)")
:format(nightPlane, World.FADE_STEPS * World.FADE_STEP_FRAMES))
end
print("[exit-fade-2141] " .. (fails == 0 and "PASS all claims" or
(fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out)
love.event.quit(fails == 0 and 0 or 1)
+75
View File
@@ -0,0 +1,75 @@
-- ../pokecrystal/home/fade.asm:35-62
-- ../pokecrystal/engine/battle/core.asm DrawEnemyHUDBorder / DrawPlayerHUDBorder
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-hud-fade"
os.execute("mkdir -p '" .. out .. "'")
local shotN = 0
local function shot(tag)
shotN = shotN + 1
game.capturePath = string.format("%s/%03d_%s.png", out, shotN, tag)
end
local fails = 0
local function ok(cond, msg)
if not cond then fails = fails + 1 end
print("[hud-fade] " .. (cond and "PASS " or "FAIL ") .. msg)
return cond
end
U.wait(60)
local world = game.world
assert(world and world.map, "the crystal world did not boot")
local save, data = game.save, game.data
save.party = { Mon.new(data, "CYNDAQUIL", 12) }
local wild = Mon.new(data, "SENTRET", 3)
ok(world:startBattle({ wild = wild }), "a wild battle starts")
local screen
for _ = 1, 600 do
U.wait(1)
local top = game.stack:top()
if top and top.battle then screen = top break end
end
ok(screen ~= nil, "the battle screen is up")
for _ = 1, 900 do
if screen.phase == "menu" then break end
U.tap(game, "a")
U.wait(2)
end
ok(screen.phase == "menu", "reached the battle menu")
U.wait(20)
shot("hud_before_fade")
U.wait(6)
U.tap(game, "down")
U.wait(4)
U.tap(game, "right")
U.wait(4)
U.tap(game, "a")
local rows, taken = {}, {}
for _ = 1, 900 do
if game.stack:top() ~= screen then break end
if screen.phase == "fadeout" then
local row = screen:exitFadeBgp()
if row and not taken[row] then
taken[row] = true
rows[#rows + 1] = row
shot(("hud_fade_row_%02x"):format(row))
end
elseif (screen.messageTimer or 0) > 0 then
U.tap(game, "a")
end
U.wait(1)
end
local rowText = {}
for _, row in ipairs(rows) do rowText[#rowText + 1] = ("%02x"):format(row) end
ok(table.concat(rowText, ",") == "90,40,00",
"shot every fade row (" .. table.concat(rowText, ",") .. ")")
print("[hud-fade] " .. (fails == 0 and "PASS all claims" or
(fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out)
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,136 @@
-- ../pokecrystal/engine/battle/core.asm:3208
-- ../pokecrystal/engine/battle/core.asm:5156
-- ../pokecrystal/home/text.asm:630
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-battle-ui-2173"
os.execute("mkdir -p '" .. out .. "'")
local shotN = 0
local function shot(tag)
shotN = shotN + 1
game.capturePath = string.format("%s/%03d_%s.png", out, shotN, tag)
U.wait(2)
end
local fails = 0
local function ok(cond, msg)
if not cond then fails = fails + 1 end
print("[battle-ui-2173/2174/2175] " .. (cond and "PASS " or "FAIL ") .. msg)
return cond
end
U.wait(60)
local world, data, save = game.world, game.data, game.save
assert(world and world.map, "the crystal world did not boot")
save.party = {
Mon.new(data, "CYNDAQUIL", 20),
Mon.new(data, "TOTODILE", 20),
}
save.inventory = { POKE_BALL = 5 }
world:warpToMapId("NEW_BARK_TOWN", 13, 7, "down")
for _ = 1, 120 do
if not world:busy() then break end
U.wait(1)
end
local function screenUp()
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
end
-- ShowSetEnemyMonAndSendOutAnimation runs (core.asm:3221)
local trainer = {
class = 1, classId = "YOUNGSTER", memberId = "JOEY",
name = "YOUNGSTER JOEY", trainerName = "JOEY", className = "YOUNGSTER",
party = { Mon.new(data, "RATTATA", 4) },
baseMoney = 12,
}
assert(world:startBattle({ trainer = trainer }), "trainer battle failed")
local screen = screenUp()
ok(screen ~= nil, "the trainer battle screen is up")
local sawTrainer, sawEmpty, popped = false, false, false
local shotEmpty = false
for _ = 1, 2400 do
if screen.showEnemyTrainer then sawTrainer = true end
if sawTrainer and not screen.showEnemyTrainer and not screen.afterSendOut
and not screen.anim and not screen.showEnemyHud then
if screen:picBoxCleared("enemy") then
sawEmpty = true
if not shotEmpty then shotEmpty = true shot("2173_box_empty") end
else
popped = true
end
end
if screen.showEnemyHud then break end
if (screen.messageTimer or 0) > 0 then U.tap(game, "a") end
U.wait(1)
end
ok(sawTrainer, "the trainer frontpic stood in the enemy box")
ok(sawEmpty, "the box was empty across SlideBattlePicOut -> the send-out")
ok(not popped, "the mon never popped in before ANIM_SEND_OUT_MON")
local held = false
for _ = 1, 1800 do
if (screen.messageTimer or 0) > 0 and screen.typer and screen.typer:done()
then held = true break end
U.wait(1)
end
ok(held, "a battle line is holding for PromptButton")
if held then
screen.arrowBlink = 0
ok(screen:messageArrowVisible(), "the down arrow is on at blink phase 0")
shot("2174_arrow_on")
screen.arrowBlink = 16
ok(not screen:messageArrowVisible(),
"UnloadBlinkingCursor blanks it at phase 16")
shot("2174_arrow_off")
screen.arrowBlink = 0
end
for _ = 1, 2400 do
if screen.phase == "menu" then break end
if (screen.messageTimer or 0) > 0 or screen.phase == "stats-box" then
U.tap(game, "a")
end
U.wait(1)
end
ok(screen.phase == "menu", "reached the battle menu")
-- accepted switch calls CloseWindow (core.asm:5162)
U.tap(game, "right")
U.wait(4)
U.tap(game, "a")
local list
for _ = 1, 300 do
local top = game.stack:top()
if top and top ~= screen then list = top break end
U.wait(1)
end
ok(list ~= nil, "the party list opened")
U.wait(6)
U.tap(game, "a")
U.wait(6)
U.tap(game, "a")
U.wait(8)
local result = list and list.itemResult
ok(game.stack:top() == list, "the party list is still on screen")
ok(list and list.submenu == nil, "the SWITCH/STATS/CANCEL box is gone")
ok(result and (result.text or ""):find("already out"),
"BattleText_MonIsAlreadyOut printed over the list")
ok(screen.phase == "submenu", "the battle screen never took the box back")
shot("2175_already_out")
U.tap(game, "a")
U.wait(8)
ok(list and list.itemResult == nil and game.stack:top() == list,
"A dismisses the line back to a live list")
shot("2175_list_back")
print("[battle-ui-2173/2174/2175] " .. (fails == 0 and "PASS all claims" or
(fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out)
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,74 @@
local U = require("tests.drivers.util")
-- ../pokecrystal/engine/events/overworld.asm:755-846
local MAP = "DARK_CAVE_VIOLET_ENTRANCE"
local BACK = "ROUTE_31"
return function(game)
local fails = 0
local function say(line) print("[dig2178] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map) then
say("FAIL the crystal world did not boot")
love.event.quit(1)
return
end
local mouth = world.maps and world.maps[MAP] and world.maps[MAP].warps
and world.maps[MAP].warps[1]
ok(mouth ~= nil, MAP .. " carries its Route 31 mouth")
if not mouth then love.event.quit(1) return end
world:warpToMapId(MAP, mouth.x, mouth.y + 1, "down")
U.wait(60)
ok(world.map and world.map.id == MAP, "arrived in the cave")
world.noWildEncounters = true
world.backupWarp = { map = BACK, warp = 3 }
local destMapId, destWarp = world:escapeRopeTarget()
ok(destMapId == BACK, "the rope resolves back to " .. BACK)
if not destWarp then love.event.quit(1) return end
U.shot(game, "/tmp/pokeport-shots/dig2178_before.png")
ok(world:runEscapeWarp(destMapId, destWarp), "the dig warp runs")
ok(world.moveState ~= nil, ".DigOut armed a movement stream")
for i = 1, 4 do
U.wait(8)
U.shot(game, ("/tmp/pokeport-shots/dig2178_spin%d.png"):format(i))
end
U.wait(8)
ok(world.playerHidden == true, "hide_object hid the player for the fade")
for _ = 1, 300 do
if world.map and world.map.id == BACK then break end
U.wait(1)
end
ok(world.map and world.map.id == BACK, "landed on " .. BACK)
for _ = 1, 240 do
if not world.playerHidden then break end
U.wait(1)
end
ok(world.moveState ~= nil, ".DigReturn armed the return stream")
ok(not world.playerHidden, "show_object brought the player back")
for i = 1, 6 do
U.shot(game, ("/tmp/pokeport-shots/dig2178_return%d.png"):format(i))
U.wait(1)
end
local walkable = false
for _ = 1, 120 do
if not world:busy() then walkable = true break end
U.wait(1)
end
ok(walkable, "and the world is walkable again")
U.shot(game, "/tmp/pokeport-shots/dig2178_after.png")
say(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+16
View File
@@ -104,6 +104,9 @@ return function(game)
end
eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock",
"the first beat")
-- ../pokecrystal/engine/menus/intro_menu.asm:629-635
waitFor("the clock hand-off rotations",
function() return top().fade == nil end, 200)
U.wait(40)
U.shot(game, out .. "/01-oak.png")
eq(game.save.player.gender, "male", "save.player.gender")
@@ -132,7 +135,14 @@ return function(game)
"wPlayerGender after the answer")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"NamePlayer's InitName default")
-- ../pokecrystal/engine/rtc/timeset.asm:22-32, :42-43: PrintText only runs
-- once RotateFourPalettesRight is done, so wait the rotation out.
waitFor("the clock's fade in",
function() return isA(InitClock) and top().fade == nil end, 200)
U.wait(20)
U.shot(game, out .. "/02b-initclock.png")
local shotDemo = false
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
@@ -144,6 +154,12 @@ return function(game)
local shotPic = false
for _ = 1, 500 do
if isA(NamePick) then break end
-- ../pokecrystal/engine/menus/intro_menu.asm:875
if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then
U.wait(6)
U.shot(game, out .. "/02c-demowipe.png")
shotDemo = true
end
if not shotPic and isA(OakSpeech) then
local at = top().steps and top().steps[top().step]
if at and at.id == "ask_player_name" then
@@ -0,0 +1,77 @@
local U = require("tests.drivers.util")
local Permissions = require("src.world.gen2.Permissions")
-- ../pokecrystal/maps/IcePathB1F.asm:82-85
local MAP = "ICE_PATH_B1F"
local BELOW = "ICE_PATH_B2F_MAHOGANY_SIDE"
local HOLES = { { 11, 2 }, { 4, 7 }, { 5, 12 }, { 12, 13 } }
local STEP = {
down = { 0, -1, "down" }, up = { 0, 1, "up" },
left = { 1, 0, "left" }, right = { -1, 0, "right" },
}
return function(game)
local fails = 0
local function say(line) print("[hole2176] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map) then
say("FAIL the crystal world did not boot")
love.event.quit(1)
return
end
world:warpToMapId(MAP, HOLES[1][1], HOLES[1][2], "down")
U.wait(60)
ok(world.map and world.map.id == MAP, "arrived at " .. MAP)
world.noWildEncounters = true
local hx, hy, dir
for _, hole in ipairs(HOLES) do
for name, d in pairs(STEP) do
local nx, ny = hole[1] + d[1], hole[2] + d[2]
if Permissions.isWalkable(world.map:cellCollision(nx, ny)) then
hx, hy, dir = nx, ny, name
break
end
end
if dir then break end
end
ok(dir ~= nil, "found a walkable cell beside a COLL_PIT")
if not dir then love.event.quit(1) return end
world:warpToMapId(MAP, hx, hy, dir)
U.wait(60)
U.shot(game, "/tmp/pokeport-shots/hole2176_before.png")
U.tap(game, dir)
U.wait(30)
for _ = 1, 240 do
if world.skyfall then break end
U.wait(1)
end
ok(world.skyfall ~= nil, "the fall animation armed after the load")
ok(world.map and world.map.id == BELOW, "landed on " .. BELOW)
ok(world:busy(), "and the applymovement holds the overworld")
U.shot(game, "/tmp/pokeport-shots/hole2176_hidden.png")
U.wait(20)
U.shot(game, "/tmp/pokeport-shots/hole2176_falling.png")
for _ = 1, 120 do
if not world.skyfall then break end
U.wait(1)
end
ok(world.shake ~= nil, "earthquake 16 fires on the landing")
ok(world.shake and world.shake.amplitude == 1, "one pixel of it")
U.shot(game, "/tmp/pokeport-shots/hole2176_landed.png")
U.wait(30)
ok((world.player.spriteYOffset or 0) == 0, "the sprite is back on its tile")
say(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,42 @@
-- home/overworld.asm:1817
-- POKEPORT_DRIVER=tests/drivers/cycling_road_end_bug2115_test.lua \
-- POKEPORT_IDENTITY=bug2115 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.onBike = true
U.teleport(game, "ROUTE_17", 7, 135, "down")
U.wait(15)
local ow = game.overworld
check("standing near the bottom of Cycling Road on the bike",
ow.map.id == "ROUTE_17" and game.save.onBike == true)
check("the last row of ROUTE_17 is the impassable ledge tile",
not ow.map:isWalkableCell(7, 143))
U.shot(game, SHOT_DIR .. "/bug2115_before.png")
for _ = 1, 900 do
if game.overworld.map.id ~= "ROUTE_17" then break end
coroutine.yield()
end
ow = game.overworld
check(("the roll hopped the ledge and landed on %s"):format(tostring(ow.map.id)),
ow.map.id == "ROUTE_18")
U.wait(30)
U.shot(game, SHOT_DIR .. "/bug2115_route18.png")
U.log("Hands off the pad on Cycling Road: the roll should carry you all the")
U.log("way down and hop the ledge onto Route 18 with no input at all.")
U.log("#2115 was the roll pedalling in place on the last row.")
U.log("Shots in " .. SHOT_DIR .. "/bug2115_*.png")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,66 @@
-- (#2114). DoBikeSpeedup (pokered home/overworld.asm:377)
-- POKEPORT_DRIVER=tests/drivers/cycling_road_speed_bug2114_test.lua \
-- POKEPORT_IDENTITY=bug2114 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
game.save.onBike = true
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(15)
local ow = game.overworld
check("standing on Cycling Road on the bike",
ow.map.id == MAP and game.save.onBike == true)
check("the player is flagged as on a slope map", ow.player.slopeMap == true)
local y0 = ow.player.cellY
U.wait(120)
local down = ow.player.cellY - y0
check(("DOWN travelled %d cells in 120 frames"):format(down), down > 0)
U.shot(game, SHOT_DIR .. "/bug2114_down.png")
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(12)
local y1 = ow.player.cellY
U.wait(120)
local up = y1 - ow.player.cellY
game.input.state.up = false
check(("UP travelled %d cells in 120 frames"):format(up), up > 0)
check(("UP is about half of DOWN (%d vs %d)"):format(up, down), up * 2 <= down + 2)
U.shot(game, SHOT_DIR .. "/bug2114_up.png")
U.teleport(game, "ROUTE_16", 11, 4, "up")
U.wait(15)
ow = game.overworld
game.save.onBike = true
check("Route 16 is not a slope map", ow.player.slopeMap ~= true)
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(20)
check(("UP off the slope is a bike step (%s frames)")
:format(tostring(ow.player.stepFramesCur)),
ow.player.stepFramesCur == ow.player.bikeStepFrames)
game.input.state.up = false
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(10)
game.save.onBike = true
U.log("On the bike on Cycling Road, rolling south.")
U.log("Hold UP, LEFT or RIGHT: those three should feel like WALKING pace.")
U.log("Let go and roll south: twice as fast. Shots in "
.. SHOT_DIR .. "/bug2114_*.png")
while true do
coroutine.yield()
end
end
+42 -2
View File
@@ -83,8 +83,24 @@ return function(game)
local sawDepartureSpin = false
local spinShotTaken = false
local leftCave = false
for _ = 1, 240 do
local facingRuns, lastFacing, lastTick, maxTick = {}, nil, -1, 0
local function sampleSpin()
if not ow.player.spinning then return end
local tick = ow.player.spinTimer or 0
if tick == lastTick then return end
lastTick = tick
if tick > maxTick then maxTick = tick end
local _, _, _, facing = ow.player:pose()
if facing ~= lastFacing then
lastFacing = facing
facingRuns[#facingRuns + 1] = 1
else
facingRuns[#facingRuns] = facingRuns[#facingRuns] + 1
end
end
for _ = 1, 400 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
if stillCave then sampleSpin() end
-- departure-only markers: ow.teleportOut is the new pre-warp spin state,
-- and player.spinRise is the rising spin (the arrival uses spinDrop), so
-- neither can be confused with the interior arrival spin-down
@@ -123,6 +139,30 @@ return function(game)
check(sawDepartureSpin,
"DIG: departure spin appears in the cave before the fade (FIX A)")
do
local runs = {}
for i, n in ipairs(facingRuns) do runs[i] = n end
U.log("departure fixed steps:", tostring(maxTick),
"facing runs:", table.concat(runs, ","))
check(maxTick >= 130,
"DIG: the departure spin runs the cart's 135 fixed steps -- got "
.. tostring(maxTick))
check((runs[2] or 0) >= 12,
"DIG: the spin starts slow (>=12 frames a facing) -- got "
.. tostring(runs[2]))
local monotonic, prev = true, math.huge
for i = 2, math.min(#runs, 15) do
if runs[i] > prev then monotonic = false end
prev = runs[i]
end
check(monotonic,
"DIG: PlayerSpinInPlace accelerates (run lengths never grow)")
local sawRise = false
for i = 12, #runs do if runs[i] == 3 then sawRise = true end end
check(sawRise,
"DIG: the rise runs at PlayerSpinWhileMovingUp's 3 frames a step")
end
check(landedMap == "VIRIDIAN_CITY",
"DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got "
.. tostring(landedMap))
@@ -151,7 +191,7 @@ return function(game)
U.wait(2)
local ropeSpin = false
for _ = 1, 240 do
for _ = 1, 400 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
ropeSpin = true
+179
View File
@@ -0,0 +1,179 @@
-- #2171: the intro's palette rotations and ShrinkPlayer's player icon.
-- ../pokecrystal/home/fade.asm:22-101
-- ../pokecrystal/engine/menus/intro_menu.asm:627-700, :802-950
--
-- POKEPORT_DRIVER=tests/drivers/gen2_intro_fades_2171.lua \
-- POKEPORT_IDENTITY=crystal-sep04 POKEPORT_VERSION=crystal \
-- POKEPORT_TOUCH=0 POKEPORT_SHOT_DIR=<dir> love .
local U = require("tests.drivers.util")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local GameVersion = require("src.core.GameVersion")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/2171-fades"
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
local function top() return game.stack:top() end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
-- The fades block input, so the frame counter is deterministic: wait for the
-- rotation to reach `at`, then shoot. U.shot costs a couple of logic ticks
-- of its own, which is why nothing here counts frames across a shot.
local function shootFade(class, kind, at, name, label)
waitFor(("%s %s frame %d"):format(label, kind, at), function()
if not isA(class) then return false end
local f = top().fade
return f ~= nil and f.kind == kind and f.frame >= at
end, 900)
if not U.shot(game, out .. "/" .. name) then bail("no shot " .. name) end
say("OK " .. label .. " -> " .. name)
end
local version = tostring(GameVersion.get())
say("version=" .. version .. " engine=" .. tostring(GameVersion.engine()))
for _ = 1, 900 do
if isA(MainMenu) then break end
tap("start", 2)
end
if not isA(MainMenu) then
for _ = 1, 400 do
if isA(MainMenu) then break end
tap("a", 2)
end
end
waitFor("the intro menu", function() return isA(MainMenu) end)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
-- --------------------------------------------------- the NEW GAME seam
U.wait(4)
if isA(GenderSelect) then
-- Crystal: InitGender answers, and InitClock's RotateFourPalettesLeft runs
-- with the gender screen still on the LCD.
waitFor("the gender prompt to finish typing",
function() return top().typer == nil or top().typer:done() end, 300)
tap("a")
shootFade(GenderSelect, "outBlack", 20,
"2171_04_gender_to_clock_mid.png", "gender -> clock, half faded out")
waitFor("the clock screen", function() return isA(InitClock) end, 300)
shootFade(InitClock, "inBlack", 22,
"2171_04_clock_fadein_mid.png", "the clock coming back from black")
else
-- Gold and Silver: NewGame_ClearTilemapEtc blanked the screen first, so
-- RotateFourPalettesLeft runs over a cleared tilemap and has nothing on it
-- to photograph; the rotation coming back is the judgeable half.
waitFor("the clock screen", function() return isA(InitClock) end, 300)
waitFor("RotateFourPalettesLeft over the cleared tilemap", function()
return isA(InitClock) and top().blank and top().fade
and top().fade.kind == "outBlack"
end, 300)
shootFade(InitClock, "inBlack", 22,
"2171_05_newgame_to_clock_mid.png", "NEW GAME -> clock, back from black")
end
waitFor("the clock screen to settle",
function() return isA(InitClock) and top().fade == nil end, 300)
-- --------------------------------------------------- the clock -> Oak seam
for _ = 1, 120 do
if not isA(InitClock) or top().fade then break end
tap("a", 3)
end
if not isA(InitClock) then bail("the clock left before its fade") end
shootFade(InitClock, "outBlack", 14,
"2171_03_clock_to_oak_mid.png", "clock -> Oak, half faded out")
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
say("OK the speech is up")
-- --------------------------------------------------- the shrink
local shotShrink, shotWhite = false, false
for _ = 1, 900 do
if game.phase == "play" then break end
if isA(NamePick) and top().slide == nil then
top().cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end, 240)
local naming = top()
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
elseif isA(OakSpeech) then
local speech = top()
if not shotShrink and speech.shrink
and speech.shrink.frame >= OakSpeech.SHRINK_ICON + 12 then
if not U.shot(game, out .. "/2171_07_shrink_player_icon.png") then
bail("no shrink shot")
end
say("OK Intro_PlacePlayerSprite -> 2171_07_shrink_player_icon.png")
shotShrink = true
elseif shotShrink and not shotWhite and speech.fade
and speech.fade.kind == "outWhite" and speech.fade.frame >= 10 then
if not U.shot(game, out .. "/2171_08_post_shrink_fade.png") then
bail("no post-shrink shot")
end
say("OK RotateThreePalettesRight -> 2171_08_post_shrink_fade.png")
shotWhite = true
elseif speech.shrink == nil and speech.fade == nil then
tap("a", 2)
else
U.wait(1)
end
else
tap("a", 2)
end
end
if not shotShrink then bail("the shrink never put the player icon up") end
if not shotWhite then bail("the shrink never faded out to white") end
waitFor("the overworld", function()
return game.phase == "play" and game.world and game.world.map
end, 600)
say("OK reached " .. tostring(game.world.map.id))
say("PASS gen2 intro fades (" .. version .. ") in " .. out)
if log then log:close() end
love.event.quit(0)
end
@@ -0,0 +1,83 @@
-- engine/overworld/events.asm:494-510
-- engine/overworld/player_movement.asm:807-816
local U = require("tests.drivers.util")
local Permissions = require("src.world.gen2.Permissions")
return function(game)
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR")
or "/tmp/talk-bump-2170"
local fails = 0
local function say(line) print("[2170] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map) then
say("FAIL the gen2 world did not boot")
love.event.quit(1)
return
end
world:warpToMapId("CHERRYGROVE_CITY", 20, 10, "down")
U.wait(45)
local DELTA = {
left = { -1, 0 }, right = { 1, 0 }, up = { 0, -1 }, down = { 0, 1 },
}
local sx, sy, dir
for _, npc in ipairs(world.npcs or {}) do
for d, v in pairs(DELTA) do
local cx, cy = npc.cellX - v[1], npc.cellY - v[2]
if Permissions.isWalkable(world.map:cellCollision(cx, cy)) then
sx, sy, dir = cx, cy, d
break
end
end
if sx then break end
end
if not sx then
say("FAIL no reachable NPC on this map")
love.event.quit(1)
return
end
world:warpToMapId("CHERRYGROVE_CITY", sx, sy, dir)
U.wait(45)
local p = world.player
say("bumping " .. dir .. " into an NPC from " .. sx .. "," .. sy)
local sawWalk = false
for _ = 1, 40 do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
if p:walkPhase() == 1 then sawWalk = true end
end
ok(not p.moving, "the NPC never let the step start")
ok(sawWalk, "the walk-in-place cycle was running before the talk")
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
U.tap(game, "a")
U.wait(2)
game.input.state[dir] = false
ok(world:busy(), "the A press started the conversation")
ok(p:walkPhase() == 0, "the talk snapped the player to the standing frame")
ok((p.bumpFrames or 0) == 0, "with the queued bump cancelled")
U.shot(game, DIR .. "/2170-talking.png")
local stayed = true
for _ = 1, 60 do
coroutine.yield()
if world:busy() and p:walkPhase() ~= 0 then stayed = false end
end
ok(stayed, "and it stays standing for the whole box")
say(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,225 @@
-- maps/Route29.asm:432
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_battle_layout_wide_bug1709_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-battle-wide love .
local U = require("tests.drivers.util")
local Chrome = require("src.ui.gen2.Chrome")
local Mon = require("src.battle.gen2.Mon")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local Permissions = require("src.world.gen2.Permissions")
local WideBattle = require("src.ui.gen2.WideBattle")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-wide"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[wide] ok " .. label)
else
failures = failures + 1
print("[wide] FAIL " .. label .. " " .. tostring(detail))
end
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function shot(path)
if not U.shot(game, path) then failures = failures + 1 end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local layoutRow
for _, row in ipairs(OptionsMenu.ROWS) do
if row.label == "BATTLE LAYOUT" then layoutRow = row end
end
ok("OPTION carries a BATTLE LAYOUT row", layoutRow ~= nil, layoutRow)
if layoutRow then
ok("and WIDE is on its ladder", layoutRow.display.wide == "WIDE",
layoutRow.display.wide)
end
ok("the wide surface is 304x144",
WideBattle.WIDTH == 304 and WideBattle.HEIGHT == 144, WideBattle.WIDTH)
if love.window and love.window.setMode then
love.window.setMode(1280, 840, { resizable = true })
U.wait(6)
end
local winW, winH = love.graphics.getDimensions()
local wideScale = Chrome.fitScaleFor(winW, winH, WideBattle.TILES_W,
WideBattle.TILES_H)
local ox, oy = Chrome.fitOriginFor(winW, winH, wideScale,
WideBattle.TILES_W, WideBattle.TILES_H)
ok(("the wide panel fits (%dx%d, at %d,%d x%d)")
:format(winW, winH, ox, oy, wideScale), ox >= 0 and oy >= 0,
ox .. "," .. oy)
local player = Mon.new(game.data, "CYNDAQUIL", 12)
local wild = Mon.new(game.data, "PIDGEY", 4)
ok("CYNDAQUIL builds from the extracted tables",
player ~= nil and #player.moves > 0, player and #player.moves)
game.save.party = { player }
game.save.inventory = { POKE_BALL = 5, POTION = 3 }
assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed")
U.wait(8)
if not Permissions.isWalkable(world:playerCollision()) then
for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 2, 0 }, { -2, 0 } }) do
if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down")
and Permissions.isWalkable(world:playerCollision()) then
break
end
end
U.wait(8)
end
ok("the player is standing on floor, not in a wall",
Permissions.isWalkable(world:playerCollision()),
tostring(world:playerCollision()))
print(failures == 0
and "[wide] preflight PASS -- the shots below are worth looking at"
or ("[wide] preflight FAIL (%d) -- fix these before judging a pixel")
:format(failures))
game.options.battleLayout = "wide"
U.wait(6)
assert(world:startBattle({ wild = wild }), "startBattle failed")
local battle
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
ok("the battle screen came up after the transition", battle ~= nil, battle)
if not battle then
print(("[wide] FAIL no battle to shoot (%d)"):format(failures))
while true do coroutine.yield() end
end
ok("and it reports the wide layout", battle:wideLayout() == true,
battle:wideLayout())
local pw, ph = battle:panelSize()
ok("composing on 304x144", pw == 304 and ph == 144, pw .. "x" .. ph)
for _ = 1, 150 do
if battle.phase == "menu" then break end
tap("a", 2)
end
ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase)
U.wait(10)
U.log("00: the command menu on WIDE. the field (both pics, the grass, the")
U.log("bases) is the cart's 160x144 picture, centred, untouched. the foe's")
U.log("HP bar sits out in the LEFT margin at its cart tiles, yours out in")
U.log("the RIGHT one. the message box and the FIGHT/PKMN/PACK/RUN window")
U.log("run the full 38 tiles across the bottom, and the four labels are")
U.log("hard against the right edge. anything clipped at 160 is the")
U.log("surface push being wrong.")
shot(out .. "/00-wide-menu.png")
local function pointAt(index)
tap("left", 3)
tap("up", 3)
if index == 2 or index == 4 then tap("right", 3) end
if index == 3 or index == 4 then tap("down", 3) end
return battle.menuIndex == index
end
ok("the cursor is on FIGHT", pointAt(1), battle.menuIndex)
tap("a", 14)
ok("the move list opened", battle.phase == "moves", battle.phase)
U.log("01: the FIGHT list on WIDE. the name list stretches to the right")
U.log("edge; MoveInfoBox (TYPE/ and the PP) stays where the cart puts it,")
U.log("bottom left, unmoved.")
shot(out .. "/01-wide-moves.png")
tap("a", 30)
U.log("02: a move going off. the FIELD shakes and flashes; the two HUD")
U.log("blocks out in the margins must hold perfectly still, and the ▼")
U.log("prompt must sit in the bottom-right corner of the WIDE message box,")
U.log("not 18 tiles in.")
shot(out .. "/02-wide-anim.png")
for _ = 1, 200 do
if battle.phase == "menu" then break end
tap("a", 3)
end
ok("back at the menu", battle.phase == "menu", battle.phase)
ok("the cursor is on PKMN", pointAt(2), battle.menuIndex)
tap("a", 12)
U.log("03: the party list over the wide battle. it is a 160-wide screen and")
U.log("must land CENTRED in the 304 surface, on the same pixel grid -- not")
U.log("hard left, not at a different scale.")
shot(out .. "/03-party-over-wide.png")
tap("b", 12)
for _ = 1, 20 do
if battle.phase == "menu" then break end
tap("b", 4)
end
game.options.battleFit = "fill"
U.wait(8)
U.log("04: WIDE + BATTLE SIZE on FILL. the 304 surface stretches to the")
U.log("window, still whole-grid, nothing cut off either side.")
shot(out .. "/04-wide-fill.png")
game.options.battleFit = "fixed"
U.wait(8)
game.options.battleBg = "black"
U.wait(8)
U.log("05: WIDE + BATTLE BG on BLACK. the bars must hug the 304 rect, not")
U.log("the 160 one -- black creeping over the margins where the HUDs live")
U.log("is the surround reading the wrong panel size.")
shot(out .. "/05-wide-black.png")
ok("the cursor is on PKMN under BLACK", pointAt(2), battle.menuIndex)
tap("a", 12)
U.log("05b: the party list over a WIDE + BLACK battle. the black must still")
U.log("hug the 304 rect -- side bands intact, no white void left on any")
U.log("edge. the list itself lands centred on the 160 grid.")
shot(out .. "/05b-party-over-wide-black.png")
tap("b", 12)
for _ = 1, 20 do
if battle.phase == "menu" then break end
tap("b", 4)
end
game.options.battleBg = "world"
U.wait(8)
U.log("06: WIDE + WORLD. Route 29 dimmed around the whole wide rect. note")
U.log("the known cosmetic: during a per-scanline BGP effect the margins")
U.log("stay paper while the 160 field flashes.")
shot(out .. "/06-wide-world.png")
game.options.battleBg = "white"
U.wait(8)
game.options.battleLayout = "og"
U.wait(10)
ok("OG reads og again", battle:wideLayout() == false, battle:wideLayout())
local ogW = select(1, battle:panelSize())
ok("and composes on 160 again", ogW == 160, ogW)
U.log("07: the same battle back on OG. this must be pixel-for-pixel the")
U.log("screen the port shipped before this change -- HUDs at their cart")
U.log("tiles, 20-tile message box, ▼ at (18,17).")
shot(out .. "/07-og-unchanged.png")
game.options.battleLayout = "wide"
U.wait(8)
print(failures == 0 and "[wide] PASS gold_battle_layout_wide_bug1709"
or ("[wide] FAIL gold_battle_layout_wide_bug1709 (%d)"):format(failures))
U.log("the battle is still up on WIDE and the controls are yours.")
while true do coroutine.yield() end
end
+5
View File
@@ -103,6 +103,11 @@ return function(game)
-- `farcall InitClock` opens OakSpeech, so the clock screen is what NEW GAME
-- lands on and Oak is underneath it.
waitFor("the clock screen", function() return isA(InitClock) end, 300)
-- ../pokecrystal/engine/rtc/timeset.asm:22, :42: PrintText is on the far side
-- of RotateFourPalettesLeft and RotateFourPalettesRight.
waitFor("the clock's fade in",
function() return isA(InitClock) and top().fade == nil end, 200)
U.wait(20)
U.shot(game, out .. "/06b-initclock.png")
for _ = 1, 60 do
if isA(OakSpeech) then break end
@@ -260,8 +260,8 @@ return function(game)
if houseStairs then
local sx, sy = cellBehind(houseStairs.x, houseStairs.y, "up")
stand("REDS_HOUSE_1F", sx, sy, "up")
-- the destination decides the cue here, not the tile underfoot, so an
takeDoor("up", "Go_Inside", "taking the stairs up")
-- home/overworld.asm:689-703
takeDoor("up", "Go_Outside", "taking the stairs up")
end
-- ---- the bedroom PC (#960) ---------------------------------------------
+8 -3
View File
@@ -119,8 +119,12 @@ return function(game)
rulesetId = "gen1_faithful", kind = "vanilla", rule = { partySize = 1 },
}
local YELLOW = {}
for k, v in pairs(PROFILE) do YELLOW[k] = v end
YELLOW.version = "yellow"
Host.connect({ name = "RED", profiles = { PROFILE } })
Guest.connect({ name = "BLUE", profiles = { PROFILE } })
Guest.connect({ name = "BLUE", profiles = { YELLOW } })
if not waitFor(function()
return Host.state() == "online" and Guest.state() == "online"
end, 600, "both clients to come online") then
@@ -141,9 +145,10 @@ return function(game)
return finish()
end
local joined = Guest.joinRoom(room.code, "player", PROFILE)
local joined = Guest.joinRoom(room.code, "player", YELLOW)
waitFor(function() return joined.done end, 600, "room_join to answer")
check(joined.error == nil, "the guest joins the room: " .. tostring(joined.error))
check(joined.error == nil,
"a yellow guest joins a red room: " .. tostring(joined.error))
waitFor(function()
return Host.room() and #Host.room().players == 2
end, 600, "the host to see two players")
+11
View File
@@ -24,6 +24,10 @@ return function(game)
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
end
local function isBox(s) return getmetatable(s) == TextBox end
-- engine/menus/start_sub_menus.asm:414-416
local function isFlash(s)
return type(s) == "table" and s.t ~= nil and s.frames ~= nil
end
local function inStack(pred)
for _, s in ipairs(game.stack.states or {}) do
if pred(s) then return true end
@@ -240,6 +244,13 @@ return function(game)
U.wait(8)
end
check("the picker is gone once the message is dismissed", not inStack(isPicker))
local flashed = isFlash(top())
check("the return to the bag whites out (#2125)", flashed)
if flashed then U.shot(game, DIR .. "/bug2125_white.png") end
for _ = 1, 40 do
if not isFlash(top()) then break end
U.wait(1)
end
local back = top()
check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu")
U.shot(game, DIR .. "/bug252_back_on_bag.png")
@@ -52,12 +52,19 @@ return function(game)
pm.swapAnim ~= nil and pm.swapAnim.blank[1] == true)
U.shot(game, DIR .. "/bug2059_blank.png")
-- the second SwitchPartyMon_ClearGfx (start_sub_menus.asm:664-666)
U.wait(1)
check("the destination row blanks too (#2126)",
pm.swapAnim ~= nil and pm.swapAnim.blank[3] == true)
U.shot(game, DIR .. "/bug2126_both_blank.png")
U.wait(40)
check("the row is back after the second SFX_SWAP", pm.swapAnim == nil)
U.shot(game, DIR .. "/bug2059_redrawn.png")
U.log("bug2059_blank.png should show row 1 EMPTY (no icon, no name, no HP")
U.log("bar, no cursor) while the swap sound plays; bug2059_redrawn.png")
U.log("bar, no cursor) while the swap sound plays; bug2126_both_blank.png")
U.log("should show rows 1 AND 3 empty at the same time; bug2059_redrawn.png")
U.log("should show the full list with MAGIKARP first and RATTATA third.")
U.log("Listen for TWO swap beeps, not one. Input is yours now: press A")
U.log("twice on the same slot -- the row should still blank and beep.")
+254
View File
@@ -0,0 +1,254 @@
-- engine/items/item_effects.asm:1954
-- engine/battle/core.asm:2520
-- Not under POKEPORT_SPEED: SFX_HEAL_AILMENT rides the real-time audio clock.
-- POKEPORT_DRIVER=tests/drivers/pp_restore_menu_2155_2158.lua \
-- POKEPORT_IDENTITY=pp2155 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots2155 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local ItemEffects = require("src.inventory.ItemEffects")
local PartyMenu = require("src.ui.PartyMenu")
local MoveSelectMenu = require("src.ui.MoveSelectMenu")
local TextBox = require("src.render.TextBox")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function top() return game.stack:top() end
local function isPicker(s)
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
end
local function isMoveMenu(s) return getmetatable(s) == MoveSelectMenu end
local function isBox(s) return getmetatable(s) == TextBox end
local function inStack(pred)
for _, s in ipairs(game.stack.states or {}) do
if pred(s) then return true end
end
return false
end
U.log("======== #2155/#2158 PP restore: machine checks ========")
for _, id in ipairs({ "ELIXER", "MAX_ELIXER", "ETHER", "MAX_ETHER", "PP_UP" }) do
check(id .. " keeps the party menu drawn",
ItemEffects.keepsPartyMenuOpen(id) == true)
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
end
check("MoveSelectMenu draws", type(MoveSelectMenu.draw) == "function")
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
local topped = Pokemon.new(game.data, "SNORLAX", 40)
for _, mv in ipairs(lead.moves) do mv.pp = 1 end
game.save.party = { lead, topped }
game.save.player.name = "RED"
for _, id in ipairs({ "ELIXER", "MAX_ELIXER", "ETHER", "MAX_ETHER" }) do
Bag.add(game.save, id, 5)
end
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local function cursorTo(menu, want)
for _ = 1, 40 do
if not menu or menu.index == want then return menu and menu.index == want end
U.tap(game, menu.index < want and "down" or "up")
U.wait(3)
end
return menu.index == want
end
local function openPickerFor(id)
U.tap(game, "start")
U.wait(10)
local menu = top()
if not (menu and menu.screenId == "StartMenu") then
return nil, "start menu never opened"
end
local itemRow
for i, it in ipairs(menu.items or {}) do
if it.label == "ITEM" then itemRow = i break end
end
if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end
U.tap(game, "a")
U.wait(10)
local bag = top()
if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end
local bagRow
for i, r in ipairs(bag.items or {}) do
if r.value == id then bagRow = i break end
end
if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end
U.tap(game, "a")
U.wait(10)
local ut = top()
if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then
if not cursorTo(ut, 1) then return nil, "USE row unreachable" end
U.tap(game, "a")
U.wait(10)
end
local picker = top()
if not isPicker(picker) then return nil, "party picker never opened" end
return picker
end
local function backToOverworld()
for _ = 1, 30 do
if top() == game.overworld then return true end
U.tap(game, "b")
U.wait(6)
end
return top() == game.overworld
end
local function dismiss()
for _ = 1, 30 do
if not inStack(isPicker) then break end
U.tap(game, "a")
U.wait(8)
end
end
local function waitForBag()
for _ = 1, 60 do
local t = top()
if t ~= nil and t.screenId == "BagMenu" then break end
U.wait(1)
end
end
U.log("======== #2155 ELIXER: SFX_HEAL_AILMENT over a live party menu ========")
local picker, why = openPickerFor("ELIXER")
check("party picker opened for ELIXER" .. (why and (" (" .. why .. ")") or ""),
picker ~= nil)
if picker then
check("keepOpen is set for the ELIXERs", picker.keepOpen == true)
cursorTo(picker, 1)
U.tap(game, "a")
U.wait(6)
check("the party menu is still on the stack after the pick", inStack(isPicker))
-- engine/items/item_effects.asm:2022
check("the cursor is still drawn", picker.cursorsErased ~= true)
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
check("the PP restored box opened", isBox(top()))
check("...over the STILL-drawn party menu", inStack(isPicker))
check("every slot came back up", lead.moves[1].pp > 1)
U.log("LISTEN", "SFX_HEAL_AILMENT should have played as the box opened")
U.wait(60)
U.shot(game, DIR .. "/pp2155_elixir_message_over_party.png")
dismiss()
check("the picker is gone once the message is dismissed", not inStack(isPicker))
-- pokered engine/items/item_effects.asm:1244
waitForBag()
check("and we are back on the ITEM list",
top() ~= nil and top().screenId == "BagMenu")
end
backToOverworld()
U.log("======== #2155 MAX ELIXER refusal prints over the party menu ========")
local refuse = openPickerFor("MAX_ELIXER")
if check("party picker opened for MAX ELIXER", refuse ~= nil) then
cursorTo(refuse, 2) -- the untouched SNORLAX
U.tap(game, "a")
U.wait(6)
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
check("the no-effect box opened", isBox(top()))
check("...over the STILL-drawn party menu", inStack(isPicker))
U.wait(60)
U.shot(game, DIR .. "/pp2155_max_elixir_no_effect.png")
dismiss()
end
backToOverworld()
U.log("======== #2158 ETHER: wMoveMenuType 2 window over the party menu ========")
for _, mv in ipairs(lead.moves) do mv.pp = 1 end
local ether = openPickerFor("ETHER")
if check("party picker opened for ETHER", ether ~= nil) then
check("keepOpen is set for the ETHERs", ether.keepOpen == true)
cursorTo(ether, 1)
U.tap(game, "a")
U.wait(8)
check("the move window is the top state", isMoveMenu(top()))
check("...with the party menu still drawn underneath", inStack(isPicker))
U.wait(20)
U.shot(game, DIR .. "/pp2158_ether_move_window.png")
-- engine/items/item_effects.asm:1985
U.tap(game, "b")
U.wait(8)
check("B goes back to the party menu", isPicker(top()))
U.tap(game, "a")
U.wait(8)
check("and the move window opens again", isMoveMenu(top()))
local before = lead.moves[1].pp
U.tap(game, "a")
U.wait(6)
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
check("the PP restored box opened", isBox(top()))
check("...over the STILL-drawn party menu", inStack(isPicker))
check("the picked move gained PP", lead.moves[1].pp > before)
U.log("LISTEN", "SFX_HEAL_AILMENT should have played for the ETHER too")
U.wait(60)
U.shot(game, DIR .. "/pp2158_ether_message_over_party.png")
dismiss()
-- pokered engine/items/item_effects.asm:1244
waitForBag()
check("back on the ITEM list", top() ~= nil and top().screenId == "BagMenu")
end
backToOverworld()
U.log("======== #2158 MAX ETHER refusal keeps both windows up ========")
local maxEther = openPickerFor("MAX_ETHER")
if check("party picker opened for MAX ETHER", maxEther ~= nil) then
cursorTo(maxEther, 2) -- the untouched SNORLAX
U.tap(game, "a")
U.wait(8)
check("the move window opened", isMoveMenu(top()))
U.tap(game, "a")
U.wait(6)
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
check("the no-effect box opened", isBox(top()))
check("...over the STILL-drawn party menu", inStack(isPicker))
U.wait(60)
U.shot(game, DIR .. "/pp2158_max_ether_no_effect.png")
dismiss()
end
backToOverworld()
U.log(("======== PP restore driver: %d passed, %d failed ========")
:format(pass, fail))
U.log(fail == 0 and "DRIVER PASS" or "DRIVER FAIL")
for _, mv in ipairs(lead.moves) do mv.pp = 1 end
local handoff = openPickerFor("ETHER")
if handoff then
cursorTo(handoff, 1)
U.tap(game, "a")
U.wait(8)
end
U.log("The ETHER move window is open over the party menu. Compare it with")
U.log("shots/issue_2158_1.png: a 16x6 box at tile 4,7, four names single")
U.log("spaced with '-' for empty slots, no PP column, cursor at column 5,")
U.log("and 'Restore PP of / which technique?' in the bottom box. B returns")
U.log("to the party menu; A restores PP with the Heal_Ailment jingle.")
while true do
coroutine.yield()
end
end
@@ -72,6 +72,19 @@ return function(game)
end
U.shot(game, SHOT_DIR .. "/candy_statbox_1_stats.png")
-- pokered engine/items/item_effects.asm:1392-1394
local PartyMenu = require("src.ui.PartyMenu")
local picker
for _, s in ipairs(game.stack.states) do
if getmetatable(s) == PartyMenu then picker = s end
end
if check("the party menu is still under the stat window", picker ~= nil) then
local msg = picker:bottomMessage() or ""
check("its bottom box still holds the level line, not the item prompt "
.. "(#2161): " .. msg:gsub("\n", " "),
msg:find("Use item") == nil)
end
U.tap(game, "a")
check("the stat window is still on the stack after its A press",
findStat() ~= nil)
+60
View File
@@ -200,4 +200,64 @@ return function(game)
U.shot(game, DIR .. "/cer_4_done.png")
U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL),
"billShot:", tostring(billShot), "exitShot:", tostring(exitShot))
Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL")
game.save.party = { tank() }
U.teleport(game, "CERULEAN_CITY", 21, 7, "up")
ow = game.overworld
U.shot(game, DIR .. "/cer21_0_before.png")
U.hold(game, "up", 20)
local firstX, firstY, spawnShot = nil, nil, false
local path, lastCell = {}, nil
local keptSprite, wipeShot = nil, false
for _ = 1, 1200 do
local r = rivalNpc()
if r and r.cellX then
if not firstX then
firstX, firstY = r.cellX, r.cellY
U.log("cer21 rival first visible at", firstX, firstY)
end
local cell = r.cellX .. "," .. r.cellY
if cell ~= lastCell then
lastCell = cell
path[#path + 1] = cell
end
if not spawnShot then
spawnShot = true
U.shot(game, DIR .. "/cer21_1_spawn.png")
end
end
if ow.battleOamKeep ~= nil and not keptSprite then
keptSprite = ow.battleOamKeep
U.log("cer21 battleOamKeep:",
tostring(keptSprite and keptSprite.def and keptSprite.def.name))
end
local wipe = game.renderer and game.renderer.battleWipe
if wipe and wipe.prog and wipe.prog > 0.2 and wipe.prog < 0.8
and not wipeShot then
wipeShot = true
U.shot(game, DIR .. "/cer21_2_wipe.png")
end
local top = game.stack:top()
if top ~= ow then
if top.phase then break end
U.tap(game, "a") -- advance the pre-battle box
end
U.wait(1)
end
U.log("cer21 approach path:", table.concat(path, " -> "))
U.log(firstX == 21 and "PASS #2149 rival spawns in the player's column"
or ("FAIL #2149 rival first seen at x=" .. tostring(firstX)))
U.log(#path <= 4 and "PASS #2149 approach is three straight steps"
or ("FAIL #2149 approach took " .. (#path - 1) .. " steps"))
U.log(keptSprite and keptSprite ~= false
and "PASS #2150 the rival's OAM block survives the wipe"
or "FAIL #2150 battleOamKeep was false, the rival is culled")
U.log(wipeShot and "wipe frame captured" or "no wipe frame captured")
winBattle("cerulean21")
mashUntil(function() return idle(ow) end, "cerulean21 done", 2000)
U.shot(game, DIR .. "/cer21_3_done.png")
end
@@ -0,0 +1,29 @@
-- ../pokeyellow/scripts/Route24.asm:149-158
-- ../pokeyellow/engine/events/give_pokemon.asm:45-46
-- POKEPORT_DRIVER=tests/drivers/route24_gift_jingles_2156_2159.lua \
-- POKEPORT_IDENTITY=yellow-sep02 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.newGame(game)
local flags = game.save.flags
flags.EVENT_GOT_NUGGET = nil
flags.EVENT_BEAT_ROUTE24_ROCKET = nil
flags.EVENT_54F = nil
U.teleport(game, "ROUTE_24", 10, 16, "up")
U.wait(30)
U.shot(game, DIR .. "/route24_jingles_2156_2159.png")
U.log("PASS setup: ROUTE_24 (10,16), nugget and Charmander quests cleared")
U.log("#2156 step 1: press UP onto (10,15) -- the recruiter talks.")
U.log("#2156 expect: the fanfare plays at the END of the")
U.log(" 'You beat our 5 contest trainers!' box, before the arrow.")
U.log("#2156 expect: the NUGGET receipt box plays SFX_GET_KEY_ITEM on")
U.log(" Yellow, SFX_GET_ITEM_1 on Red/Blue.")
U.log("#2159 step 2: walk north to Damian and accept his CHARMANDER.")
U.log("#2159 expect: 'got CHARMANDER!' + SFX_GET_ITEM_1 BEFORE the")
U.log(" nickname prompt.")
end
+21 -2
View File
@@ -127,6 +127,14 @@ return function(game)
if landed and not o.transitioning then break end
U.wait(1)
end
-- spins the player down -- player_animations.asm:41-45
local hidden = false
for _ = 1, 200 do
local o = game.overworld
if o.holeArrive then hidden = hidden or o.playerHidden end
if not (o.holeArrive or o.player.spinning) then break end
U.wait(1)
end
U.wait(20)
watching = false
@@ -152,6 +160,15 @@ return function(game)
check("no door chime over the fall (heard: " ..
(#cues > 0 and table.concat(cues, ", ") or "nothing") .. ")",
#doors == 0)
-- player_animations.asm:12
local faint, enter = false, false
for _, c in ipairs(cues) do
if c == "Faint_Fall" then faint = true end
if c == "Teleport_Enter1" then enter = true end
end
check("the departure is silent (no invented Faint_Fall)", not faint)
check("the arrival rings Teleport_Enter1", enter)
check("the player is parked offscreen through the 50-frame hold", hidden)
U.shot(game, f.shot)
U.log("captured", f.shot)
end
@@ -165,8 +182,10 @@ return function(game)
U.wait(10)
U.log("Both falls have already run; press up from where you are standing to")
U.log("do the 1F one again. It should be a plain fade to black and back with")
U.log("the player on B1F at (18,7) still facing up, no door chime, and no")
U.log("do the 1F one again. It should sink the sprite, fade to WHITE, hold")
U.log("about a second on an empty B1F (18,7) with the player offscreen, ring")
U.log("Teleport_Enter1 and spin him down into the cell still facing up -- no")
U.log("faint sound, no door chime, and no")
U.log("step out of a doorway on the far side. The near miss to watch for is")
U.log("landing one cell off, on the boulder's own spot from field.seafoam")
U.log("landsAt, which strands you inside the rock; and dropping onto the B3F")
@@ -49,7 +49,7 @@ return function(game)
}
run(ow)
Music.play = realPlay
return rows
return rows, ow
end
-- ---------------------------------------------------------- rival exit
@@ -63,9 +63,27 @@ return function(game)
for _, side in ipairs({ { 37, 4 }, { 36, 6 } }) do
local x, steps = side[1], side[2]
local rows = capture(function(ow)
local rows, capturedOw = capture(function(ow)
story5[ANNE2F].onStep({ save = { flags = {} }, data = game.data }, ow, x, 8)
end)
-- SSAnne2FSetFacingDirectionScript (scripts/SSAnne2F.asm:73)
check(("x=%d the player keeps his walking facing at trigger time"):format(x),
capturedOw.player.facing == "down")
local turnIndex, moveIndex, textIndex
for i, r in ipairs(rows) do
if r[1] == "face_player_dir" and not turnIndex then turnIndex = i end
if r[1] == "move_npc_to" and not moveIndex then moveIndex = i end
if r[1] == "show_text" and not textIndex then textIndex = i end
end
if x == 37 then
check("x=37 turns the player LEFT as a scripted row",
turnIndex ~= nil and rows[turnIndex][2] == "left")
check("x=37 turns him after the walk and before the dialogue",
turnIndex ~= nil and moveIndex ~= nil and textIndex ~= nil
and turnIndex > moveIndex and turnIndex < textIndex)
else
check("x=36 never writes the player's direction", turnIndex == nil)
end
local said = rowsOfKind(rows, "show_text")
check(("x=%d the goodbye is the last thing he says"):format(x),
said[#said] ~= nil and said[#said][2] == CUT)
+72
View File
@@ -0,0 +1,72 @@
-- (text/PalletTown.asm:44)
-- show, on the second text row (home/text.asm:214)
-- SHOT_DIR=/tmp/bug2116 POKEPORT_DRIVER=tests/drivers/text_arrow_bug2116_test.lua \
-- POKEPORT_IDENTITY=<a v11 cache> POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local pass = true
local function check(msg, cond)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function box()
local top = game.stack:top()
return getmetatable(top) == TextBox and top or nil
end
-- bg_event 7, 9 (data/maps/objects/PalletTown.asm:16)
U.teleport(game, "PALLET_TOWN", 7, 10, "up")
U.wait(8)
U.tap(game, "a")
U.wait(10)
local tb = box()
check("the sign opened a text box", tb ~= nil)
if not tb then U.log("RESULT: SEE FAILURES ABOVE") return end
U.log("_PalletTownSignText = "
.. string.format("%q", tostring(game.data.text._PalletTownSignText)))
check("the extracted line ends in a done marker",
(game.data.text._PalletTownSignText or ""):find("{DONE}", 1, true) ~= nil)
-- the `cont` wait: home/text.asm:263
local sawCont = false
for _ = 1, 400 do
if tb.waiting then sawCont = true break end
U.wait(1)
end
check("the cont page waits on the arrow", sawCont)
if sawCont then
local x, y = tb:arrowPos()
U.log(("arrowPos = %d,%d line2Y = %d"):format(x, y, tb.line2Y))
check("the arrow is drawn at the cont wait", tb:arrowVisible())
check("column 18 (x=144)", x == 144)
check("row 16 (y=128), the second text row", y == 128 and y == tb.line2Y)
tb.blink = 0
U.shot(game, DIR .. "/bug2116_1_cont_arrow.png")
end
for _ = 1, 120 do
if tb.done then break end
U.tap(game, "a")
U.wait(4)
end
check("the text finished", tb.done == true)
-- home/text_script.asm:96 -> home/joypad2.asm:71-72
check("a `done` text still blinks the arrow", tb:arrowVisible())
-- home/joypad2.asm:90-92
check("and closes without SFX_PRESS_AB", tb.waitButton == true)
tb.blink = 0
U.shot(game, DIR .. "/bug2116_2_done_arrow_no_beep.png")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
U.log("bug2116_1_cont_arrow.png: the blinking triangle sits on the same")
U.log("row as the second line of text, not jammed on the bottom border.")
U.log("bug2116_2_done_arrow_no_beep.png: the final page of a `done` text")
U.log("still shows the triangle -- WaitForTextScrollButtonPress blinks it")
U.log("for every Gen 1 text; only the A-press beep is missing.")
U.log("Shots are in " .. DIR)
end
+13 -1
View File
@@ -54,7 +54,7 @@ return function(game)
check(("sfx volume %d"):format(vol), vol > 0)
-- the record and the staircase it drives, before any of it is on screen
local fade = Transition.new(game)
local fade = Transition.new(game, nil, nil, true)
check("the warp fade runs 32 frames with no fade in",
fade.frames == 32 and (fade.framesIn or 0) == 0)
fade.phase = "out"
@@ -111,6 +111,7 @@ return function(game)
return getmetatable(top) == Transition and top or nil
end
local seen, order = {}, {}
local bytes = {}
for _ = 1, 140 do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
@@ -119,6 +120,8 @@ return function(game)
local a = live:alpha()
if not seen[a] then seen[a] = 0; order[#order + 1] = a end
seen[a] = seen[a] + 1
local b = live:bgp()
if bytes[#bytes] ~= b then bytes[#bytes + 1] = b end
elseif #order > 0 then
break
end
@@ -141,6 +144,15 @@ return function(game)
check("each shade holds its eight frames", held)
check("we are back outside on " .. MAP, ow.map.id == MAP)
-- staircase has to be FadePal4/3/2/1 (home/fade.asm:49)
local hex = {}
for _, b in ipairs(bytes) do hex[#hex + 1] = ("$%02X"):format(b) end
U.log("rBGP bytes written on the way out:", table.concat(hex, ", "))
local want = { 0xE4, 0xF9, 0xFE, 0xFF }
local walked = #bytes == #want
for i, b in ipairs(want) do if bytes[i] ~= b then walked = false end end
check("the exit fade walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1", walked)
-- hand the doorstep back so this can be re-triggered by hand
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above"))
@@ -0,0 +1,76 @@
-- (../pokeyellow/engine/gfx/palettes.asm:178)
-- POKEPORT_DRIVER=tests/drivers/yellow_map_seam_bug2152_test.lua \
-- POKEPORT_IDENTITY=bsa2180 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow \
-- SHOT_DIR=/tmp/shots2152 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots2152"
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
U.newGame(game)
local ow = game.overworld
U.teleport(game, "PALLET_TOWN", 10, 1, "up")
U.wait(20)
check("standing on the Pallet Town / Route 1 boundary",
ow.map.id == "PALLET_TOWN")
check("a neighbour map is resident", #ow.neighbors > 0)
local zones = ow:sgbWorldZones()
U.log("world zones at FIT:", zones and #zones or "none")
check("FIT publishes one whole-view zone (BlkPacket_WholeScreen)",
zones == nil or #zones <= 1)
check("the boundary frame captured",
U.shot(game, DIR .. "/seam_1_fit.png"))
Zoom.offset = -2
U.wait(10)
local surveyed = ow:sgbWorldZones()
U.log("world zones at survey zoom:", surveyed and #surveyed or "none")
check("survey zoom keeps a zone per resident neighbour",
surveyed == nil or #surveyed > 1)
U.shot(game, DIR .. "/seam_2_survey.png")
Zoom.offset = 0
U.wait(10)
U.teleport(game, "ROUTE_1", 10, 1, "up")
U.wait(20)
local lane
for x = 0, 19 do
if ow.map:isWalkableCell(x, 0) and ow.map:isWalkableCell(x, 1) then
lane = x
break
end
end
check("Route 1 has a walkable lane to Viridian", lane ~= nil)
if lane then
U.teleport(game, "ROUTE_1", lane, 1, "up")
U.wait(20)
U.hold(game, "up", 120)
U.wait(20)
end
U.shot(game, DIR .. "/seam_3_crossed.png")
U.log("map after walking north:", ow.map.id)
check("walked onto VIRIDIAN_CITY", ow.map.id == "VIRIDIAN_CITY")
local after = ow:sgbWorldZones()
check("the new map is also a single whole-view zone at FIT",
after == nil or #after <= 1)
U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above"))
U.log("seam_1_fit.png must show ONE palette across the whole screen, with no")
U.log("horizontal colour break above the player and no split down his sprite.")
U.log("seam_2_survey.png is the survey zoom and SHOULD still show each town")
U.log("and route in its own colours. seam_3_crossed.png is Viridian after")
U.log("the crossing, entirely in the new map's palette.")
while true do
coroutine.yield()
end
end
@@ -53,6 +53,41 @@ do
eq(got, nil, "no npc and no keep: the player only")
end
-- home/text_script.asm:8
do
local rival = { id = "rival" }
local other = { id = "other" }
local got
local ow = {
player = player,
pushBattle = function(_, _, keep) got = keep end,
npcByIndex = function(_, i)
if i == 1 then return rival elseif i == 2 then return other end
end,
}
local ctx = { overworld = ow, runner = {} }
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, nil, "an unaddressed ambush still keeps nothing")
Commands.face_object(ctx, 1, "down")
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, rival, "face_object stamps the sprite an ambush battle keeps")
Commands.place_npc(ctx, 2, 3, 4)
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, other, "place_npc restamps, like hSpriteIndex being overwritten")
Commands.walk_npc(ctx, 1, {})
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, rival, "walk_npc stamps the object it moves")
Commands.walk_npc(ctx, "player", {})
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, rival, "walk_npc \"player\" leaves the stamp alone")
local talked = { id = "talked" }
ctx.npc = talked
Commands.pushBattle(ctx, { kind = "trainer" })
eq(got, talked, "a talked-to npc still wins over the script stamp")
Commands.pushBattle(ctx, { kind = "trainer" }, other)
eq(got, other, "an explicit keep still wins over both")
end
do
Renderer.wipeSprites = function() end
Renderer.wipeWox, Renderer.wipeWoy = 1, 2
+149
View File
@@ -0,0 +1,149 @@
-- engine/pokemon/bills_pc.asm:176
-- :118 PokeballTileGraphics; data/text/text_3.asm:30
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local Menu = require("src.ui.Menu")
local Theme = require("src.ui.Theme")
local BoxMenu = require("src.ui.BoxMenu")
local Boxes = require("src.pokemon.Boxes")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function stubGame()
local game = { data = Data, save = SaveData.newGame() }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
wasPressed = function() return false end,
isDown = function() return false end,
}
return game
end
-- home/window.asm:198
local cursors
local realDrawCode = Font.drawCode
Font.drawCode = function(code, x, y)
cursors[#cursors + 1] = { code = code, x = x, y = y }
end
local function cursorCodes(menu)
cursors = {}
menu:draw()
local out = {}
for _, g in ipairs(cursors) do
if g.code == Theme.cursor or g.code == Theme.cursorHollow then
out[#out + 1] = g.code
end
end
return out
end
do
local menu = Menu.new(stubGame(), {
{ label = "ONE" }, { label = "TWO" },
})
menu.index = 2
eq(cursorCodes(menu)[1], Theme.cursor, "a plain Menu draws the filled arrow")
menu.hollowIndex = 1
eq(cursorCodes(menu)[1], Theme.cursor,
"a hollow mark on another row leaves the cursor filled")
menu.hollowIndex = 2
eq(cursorCodes(menu)[1], Theme.cursorHollow,
"hollowIndex on the cursor row draws the unfilled arrow")
end
-- bills_pc.asm:176
do
local game = stubGame()
local menu = BoxMenu.new(game)
game.stack:push(menu)
local changeBox
for i, item in ipairs(menu.items) do
if item.keepOpen then changeBox = changeBox or i end
end
check(changeBox, "the PC menu has keepOpen items")
menu.index = changeBox
menu.items[changeBox].onSelect()
eq(menu.hollowIndex, changeBox,
"picking a keepOpen item marks the parent cursor hollow")
eq(cursorCodes(menu)[1], Theme.cursorHollow, "and it draws hollow")
while game.stack:top() ~= menu do game.stack:pop() end
menu:update(1 / 60)
eq(menu.hollowIndex, nil, "back on top, the filled arrow returns")
eq(cursorCodes(menu)[1], Theme.cursor, "and it draws filled")
end
Font.drawCode = realDrawCode
-- engine/menus/save.asm:437
local function upvalue(fn, name)
for i = 1, 60 do
local n, v = debug.getupvalue(fn, i)
if not n then return nil end
if n == name then return v end
end
end
do
local changeBoxMenu = upvalue(upvalue(BoxMenu.new, "changeBox"), "changeBoxMenu")
check(type(changeBoxMenu) == "function", "found changeBoxMenu")
local game = stubGame()
Boxes.ensure(game.save)
game.save.boxes[2] = { Pokemon.new(Data, "FIXMON_A", 5) }
changeBoxMenu(game)
local menu = game.stack:top()
check(menu, "the CHANGE BOX menu went up")
local lines, circles, images = {}, 0, {}
local realDraw, realCircle = Font.draw, love.graphics.circle
local realImageDraw = love.graphics.draw
Font.draw = function(s) lines[#lines + 1] = s end
love.graphics.circle = function() circles = circles + 1 end
love.graphics.draw = function(img, _, x, y)
images[#images + 1] = { path = type(img) == "table" and img.path, x = x, y = y }
end
menu:draw()
Font.draw, love.graphics.circle, love.graphics.draw =
realDraw, realCircle, realImageDraw
local joined = table.concat(lines, "|")
check(joined:find("<PK><MN> BOX.", 1, true),
"the ROM-free prompt is the two-tile ligature, not POKeMON")
check(not joined:find("POKéMON BOX.", 1, true), "and never the ASCII word")
check(not joined:find("{DONE}", 1, true), "with no terminator marker")
eq(circles, 0, "the box marker is no longer a vector pokeball")
local balls = 0
for _, img in ipairs(images) do
if img.path == "assets/generated/battle/balls.png" then
balls = balls + 1
-- engine/menus/save.asm:488
eq(img.x, 144, "the ball sits on tile column 18")
eq(img.y, 16, "on box 2's row")
end
end
eq(balls, 1, "one balls.2bpp tile 0, for the one stocked box")
end
do
local strip = function(s)
return (s:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", ""))
end
eq(strip("Choose a\n<PK><MN> BOX.{DONE}"), "Choose a\n<PK><MN> BOX.",
"the draw strips the terminator the extractor now writes")
end
T.finish("bills pc change box bug2124")
@@ -0,0 +1,88 @@
-- EnemySendOutFirstMon (engine/battle/core.asm:1432-1435)
-- then PlayCry -- which ends in WaitForSoundToFinish (home/pokemon.asm:145-149)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
return { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
end
local function step(battle)
local row = table.remove(battle.queue, 1)
if row and row.fn then
battle.nextInsert = 0
row.fn()
end
return row
end
do
local game = newGame()
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.rng = function() return 0 end
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
Pokemon.new(Data, "FIXMON_C", 30) }
battle.enemyIndex = 1
battle.queue, battle.nextInsert = {}, 0
battle.enemyHudPending = nil
battle:executeAction(battle.enemy, battle.player,
{ special = "aiSwitch", index = 2 })
local grew = false
for _ = 1, 40 do
if not battle.queue[1] then break end
step(battle)
if battle.growIn then grew = true break end
end
T.check(grew, "the AI switch queues AnimateSendingOutMon")
T.eq(battle.enemyHudPending, true,
"the enemy HUD stays down across PlayCry (core.asm:1434-1435)")
T.check(battle.queue[1] and battle.queue[1].wait ~= nil,
"the grow-in hold is the next row")
step(battle)
T.check(battle.queue[1] and battle.queue[1].fn ~= nil,
"the cry act follows the grow-in hold")
step(battle)
T.check(battle.queue[1] and battle.queue[1].waitSound ~= nil,
"PlayCry's WaitForSoundToFinish row is queued")
T.eq(battle.enemyHudPending, true, "the HUD is still down during the cry")
step(battle)
T.check(battle.queue[1] and battle.queue[1].fn ~= nil,
"DrawEnemyHUDAndHPBar follows the wait row")
step(battle)
T.eq(battle.enemyHudPending, nil,
"the HUD comes up once the cry has finished")
end
do
local game = newGame()
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.growIn = nil
battle.enemyHudPending = true
T.check(battle:growInScale(battle.enemy) == nil,
"the grow-in is over")
T.check(battle.enemyHudPending and true or false,
"drawHUDs' enemy gate is closed while the cry is outstanding")
end
-- _InitBattleCommon (core.asm:6755-6763)
do
local game = newGame()
local battle = BattleState.newWild(game, "FIXMON_C", 30)
T.eq(battle.enemyHudPending, nil,
"the wild intro never gates the enemy HUD on a cry")
end
T.finish("enemy HUD waits for the send-out cry (#2118)")
+145
View File
@@ -0,0 +1,145 @@
-- (engine/battle/core.asm:2520)
-- luajit tests/engine/ether_move_menu_2158.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("ETHER move select window #2158")
local check, eq = S.check, S.eq
local ItemEffects = require("src.inventory.ItemEffects")
local SaveData = require("src.core.SaveData")
-- engine/items/item_effects.asm:2016
for _, id in ipairs({ "ETHER", "MAX_ETHER", "ELIXER", "MAX_ELIXER", "PP_UP" }) do
eq(ItemEffects.keepsPartyMenuOpen(id), true, id .. " keeps the party menu open")
end
local src = assert(io.open("src/ui/BagMenu.lua")):read("*a")
eq(src:find("Which move?", 1, true), nil, "BagMenu no longer builds a Which move? list")
check(src:find("MoveSelectMenu", 1, true) ~= nil, "BagMenu pushes MoveSelectMenu")
check(src:find("_RestorePPWhichTechniqueText", 1, true) ~= nil,
"and prints RestorePPWhichTechniqueText")
check(src:find("_RaisePPWhichTechniqueText", 1, true) ~= nil,
"and RaisePPWhichTechniqueText for PP UP")
local draws
local FontStub = {
drawBox = function(tx, ty, tw, th) draws[#draws + 1] = { "box", tx, ty, tw, th } end,
draw = function(text, x, y) draws[#draws + 1] = { "text", text, x, y } end,
drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end,
}
package.loaded["src.render.Font"] = FontStub
local MoveSelectMenu = require("src.ui.MoveSelectMenu")
local pressed = {}
local stack = {}
local game = {
input = { wasPressed = function(_, b) return pressed[b] == true end },
stack = {
push = function(_, s) stack[#stack + 1] = s end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
},
data = { moves = { SCRATCH = { name = "SCRATCH" }, EMBER = { name = "EMBER" } } },
}
local mon = { moves = { { id = "SCRATCH", pp = 5 }, { id = "EMBER", pp = 25 } } }
local chosen, cancelled
local menu = MoveSelectMenu.new(game, mon, "Restore PP of\nwhich technique?",
function(i) chosen = i end, function() cancelled = true end)
stack[1] = menu
draws = {}
menu:draw()
-- engine/battle/core.asm:2526
eq(draws[1][1], "box", "the move window is a bordered box")
eq(draws[1][2], 4, "at tile column 4")
eq(draws[1][3], 7, "tile row 7")
eq(draws[1][4], 16, "16 tiles wide")
eq(draws[1][5], 6, "6 tiles tall")
-- engine/battle/core.asm:2530
eq(draws[2][2], "SCRATCH", "slot 1 name")
eq(draws[2][3], 48, "names start at x 48")
eq(draws[2][4], 64, "slot 1 on row 8")
eq(draws[3][4], 72, "slot 2 on row 9, single spaced")
-- engine/battle/misc.asm:37
eq(draws[4][2], "-", "empty slot 3 is a dash")
eq(draws[5][2], "-", "empty slot 4 is a dash")
check(not tostring(draws[2][2]):find("5"), "no PP column on the move rows")
-- engine/battle/core.asm:2535
eq(draws[6][1], "code", "cursor glyph")
eq(draws[6][3], 40, "cursor at tile column 5")
eq(draws[6][4], 64, "cursor starts on the first move")
-- engine/items/item_effects.asm:1979
eq(draws[7][2], 0, "prompt box at column 0")
eq(draws[7][3], 12, "row 12")
eq(draws[8][2], "Restore PP of", "prompt line 1")
eq(draws[8][4], 112, "on row 14")
eq(draws[9][2], "which technique?", "prompt line 2")
eq(draws[9][4], 128, "on row 16")
local terminated = MoveSelectMenu.new(game, mon,
"Restore PP of\nwhich technique?{DONE}", function() end)
draws = {}
terminated:draw()
eq(draws[9][2], "which technique?", "the {DONE} terminator is not drawn")
-- engine/battle/core.asm:2692
pressed = { down = true }
menu:update()
eq(menu.index, 2, "down moves to slot 2")
menu:update()
eq(menu.index, 1, "and wraps past the dash slots back to slot 1")
pressed = { up = true }
menu:update()
eq(menu.index, 2, "up wraps to the last real move")
-- engine/items/item_effects.asm:1985
pressed = { b = true }
menu:update()
eq(#stack, 0, "B pops the move window")
eq(cancelled, true, "back to the party menu")
stack[1] = menu
menu.index = 2
pressed = { a = true }
menu:update()
eq(chosen, 2, "A hands back the picked slot")
eq(#stack, 0, "and pops itself first")
local empty, escaped = { moves = {} }, 0
local emptyMenu = MoveSelectMenu.new(game, empty, "Restore PP of\nwhich technique?",
function() escaped = -1 end, function() escaped = escaped + 1 end)
stack[1] = emptyMenu
pressed = { down = true }
emptyMenu:update()
eq(#stack, 1, "an empty list ignores DOWN")
eq(emptyMenu.index, 1, "and leaves the cursor on slot 1")
pressed = { b = true }
emptyMenu:update()
eq(#stack, 0, "B pops the empty move window")
eq(escaped, 1, "and runs onCancel")
stack[1] = emptyMenu
escaped = 0
pressed = { a = true }
emptyMenu:update()
eq(#stack, 0, "A on an empty list cancels too")
eq(escaped, 1, "never reporting a picked slot")
-- engine/items/item_effects.asm:2040
local Data = {
items = { ETHER = { name = "ETHER" } },
moves = { SCRATCH = { name = "SCRATCH", pp = 35 }, EMBER = { name = "EMBER", pp = 25 } },
pokemon = { CHARMANDER = { name = "CHARMANDER" } },
text = {},
}
local save = SaveData.newGame()
local t = { species = "CHARMANDER", hp = 20, stats = { hp = 20 },
moves = { { id = "SCRATCH", pp = 30 }, { id = "EMBER", pp = 5 } } }
eq((ItemEffects.use(Data, save, "ETHER", t, nil, 2)), "consumed", "ETHER on slot 2")
eq(t.moves[1].pp, 30, "slot 1 untouched")
eq(t.moves[2].pp, 15, "slot 2 gets +10")
S.finish()
@@ -102,6 +102,20 @@ end
local function isPicker(s) return getmetatable(s) == PartyMenu end
-- engine/menus/start_sub_menus.asm:414-416
local function drainFlash(game)
local top = game.stack:top()
if not (type(top) == "table" and top.t ~= nil and top.frames ~= nil) then
return false
end
local n = 0
while game.stack:top() == top and n < 240 do
top:update(1 / 60)
n = n + 1
end
return true
end
local function rowFor(list, id)
for i, r in ipairs(list.items) do
if r.value == id then return i end
@@ -176,6 +190,8 @@ do
pushed.onDone()
check(game.stack:top() ~= picker and not isPicker(game.stack:top()),
"and the picker comes down when the evolution finishes (#2070)")
check(drainFlash(game),
"with GBPalWhiteOutWithDelay3 over the seam (#2125)")
eq(game.stack:top(), list,
"landing on the ITEM list, the way .useItem_partyMenu returns to "
.. "StartMenu_Item (engine/menus/start_sub_menus.asm:419)")
@@ -205,6 +221,8 @@ do
game.stack:pop()
box.done()
end
check(drainFlash(game),
"the refusal whites out on the way back too (#2125)")
eq(game.stack:top(), list,
"closing the box drops the picker back to the ITEM list")
end
@@ -0,0 +1,140 @@
-- home/text.asm:209 PromptText, :221 DoneText; constants/charmap.asm:12
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Json = require("src.link.Json")
local Extractor = require("src.import.RomExtractor")
local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local romText = require("src.core.RomText")
local presses = 0
local realPress, realPlay = Sound.playPress, Sound.play
Sound.playPress = function() presses = presses + 1 return nil end
Sound.play = function() return nil end
local function manifest(path)
local file = assert(io.open(path, "r"))
local data = assert(Json.decode(file:read("*a")))
file:close()
return data
end
local charmap = manifest("tools/rom_manifest.json").charmap
eq(charmap["74"], "<PK><MN>",
"the manifest maps $4A to the two-tile ligature, not POKeMON")
eq(manifest("tools/rom_manifest_blue.json").charmap["74"], "<PK><MN>",
"Blue's manifest too")
eq(manifest("tools/rom_manifest_yellow.json").charmap["74"], "<PK><MN>",
"and Yellow's")
local function decode(bytes)
local rom = {}
function rom:byte(_, address) return bytes[address] or 0x50 end
local extractor = setmetatable({ rom = rom, manifest = { charmap = charmap } },
Extractor)
return extractor:decodeTextCommands(
{ bank = 0, address = 0, name = "_Test" }, {})
end
local H, i = 0x87, 0xa8
eq(decode({ [0] = 0x00, H, i, 0x57 }), "Hi{DONE}", "a `done` text carries {DONE}")
eq(decode({ [0] = 0x00, H, i, 0x58 }), "Hi{PROMPT}",
"a `prompt` text carries {PROMPT}")
-- data/text/text_3.asm:32
eq(decode({ [0] = 0x00, H, i, 0x50, 0x50 }), "Hi{DONE}",
"a text_end text is a done too")
eq(decode({ [0] = 0x00, H, i, 0x5F }), "Hi",
"<DEXEND> stays unmarked for DexEntryMenu")
eq(decode({ [0] = 0x50 }), "", "an empty text stays empty")
eq(decode({ [0] = 0x00, 0x4A, 0x57 }), "<PK><MN>{DONE}",
"$4A decodes to the ligature, whole, not a {PK><MN} token")
eq(decode({ [0] = 0x00, 0x52, 0x57 }), "{PLAYER}{DONE}",
"a lone <NAME> entry is still a token")
eq(TextBox.ending("Hi{DONE}"), "done", "TextBox reads {DONE}")
eq(TextBox.ending("Hi{PROMPT}"), "prompt", "and {PROMPT}")
eq(TextBox.ending("Hi"), nil, "and nothing for an untagged text")
local function newGame()
local game = {
save = { player = {}, options = { textSpeed = "FAST" }, generation = 1 },
data = { text = {} },
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function(self, btn) return self.queue[btn] or false end,
}
return game
end
local function finished(text, opts)
local game = newGame()
local box = TextBox.new(game, text, nil, opts)
game.stack:push(box)
local frames = 0
while not box.done and frames < 600 do
box:update(1 / 60)
frames = frames + 1
end
presses = 0
game.input.queue.a = true
box:update(1 / 60)
return box, presses
end
-- text/Route25.asm:153
do
local box, beeps = finished("SEA COTTAGE\nBILL lives here!{DONE}")
eq(box.waitButton, true, "an extracted `done` text is a WaitButton box")
-- home/text_script.asm:96 -> home/joypad2.asm:71-72
check(box:arrowVisible(), "and still blinks the arrow WaitForTextScrollButtonPress draws")
eq(select(2, box:arrowPos()), 128, "at hlcoord 18,16")
-- SFX_PRESS_AB lives only in ManualTextScroll, home/joypad2.asm:90-92
eq(beeps, 0, "and closes silently")
eq(box.pages[1][1], "SEA COTTAGE", "the marker never reaches the glyphs")
eq(box.pages[1][2], "BILL lives here!", "on either line")
end
do
local box, beeps = finished("SEA COTTAGE\nBILL lives here!{PROMPT}")
eq(box.waitButton, false, "an extracted `prompt` text blinks")
check(box:arrowVisible(), "and shows the cursor")
eq(beeps, 1, "and beeps on the press")
eq(box.pages[1][2], "BILL lives here!", "with the marker stripped")
end
do
local box = finished("Hi")
check(box:arrowVisible(), "an untagged text keeps the blinking default")
end
Sound.playPress, Sound.play = realPress, realPlay
do
local pages = TextBox.paginate("Choose a\n<PK><MN> BOX.{DONE}", 18)
eq(pages[1][1], "Choose a", "paginate drops the terminator")
eq(pages[1][2], "<PK><MN> BOX.", "and keeps the ligature")
eq(TextBox.paginate("Take your time.{PROMPT}", 18)[1][1], "Take your time.",
"for {PROMPT} as well")
end
do
local data = { text = { _PotionText = "{RAM:wcf4b} recovered\nby {NUM}!{DONE}" } }
eq(romText(data, "_PotionText", "fallback", "POLIWHIRL", 4),
"POLIWHIRL recovered\nby 4!{DONE}",
"RomText does not count the terminator as a slot")
end
T.finish("gen1 text terminator bug2116")
@@ -95,6 +95,15 @@ p:update()
eq(p:walkPhase(), 0, "and .Standing drops to the standing pose on release")
eq(p:drawFlip(), p.stepFlip, "with the sprite unmirrored again")
p = newPlayer()
p.animClock = 16
p:tryMove("right", MAP, nil)
-- engine/overworld/player_movement.asm:807
p:stopForEvent()
eq(p.bumpFrames, nil, "an event cancels the queued bump")
eq(p:walkPhase(), 0, "and the talk starts on the standing pose")
eq(p:drawFlip(), p.stepFlip, "unmirrored, whatever the clock read")
p = newPlayer()
p:tryMove("right", MAP, nil)
eq(p:tryMove("right", OPEN, nil), "moved", "an open tile steps")
+192
View File
@@ -0,0 +1,192 @@
-- #2171 the intro's missing palette rotations and ShrinkPlayer's player icon.
-- ../pokecrystal/home/fade.asm:22-113
-- ../pokecrystal/engine/rtc/timeset.asm:4-46
-- ../pokecrystal/engine/menus/intro_menu.asm:627-700, :802-950
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local eq, check = T.eq, T.check
local IntroFade = require("src.ui.gen2.IntroFade")
local InitClock = require("src.ui.gen2.InitClock")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local function fakeInput()
return {
pressed = {},
wasPressed = function(self, b)
local hit = self.pressed[b]
self.pressed[b] = nil
return hit and true or false
end,
press = function(self, b) self.pressed[b] = true end,
}
end
-- --------------------------------------------------------------- the tables
do
eq(IntroFade.STEP_FRAMES, 8, "RotatePalettesRight: ld c, 8 / DelayFrames")
eq(IntroFade.frames("outBlack"), 32,
"RotateFourPalettesLeft: b = 4 steps x 8 frames")
eq(IntroFade.frames("inBlack"), 32, "RotateFourPalettesRight, the same")
eq(IntroFade.frames("outWhite"), 24, "RotateThreePalettesRight: b = 3")
eq(IntroFade.frames("inWhite"), 24, "RotateThreePalettesLeft: b = 3")
eq(IntroFade.new("nonesuch"), nil, "and nothing else is a rotation")
-- IncGradGBPalTable_03 .. _00: the first step is still the loaded palette.
local out = IntroFade.new("outBlack")
eq(out:level(), 0, "outBlack step 1 is table 03, the normal palette")
for _ = 1, 8 do out:tick() end
eq(out:level(), 1 / 3, "step 2 is table 02")
for _ = 1, 24 do out:tick() end
check(out:done(), "32 frames is the whole rotation")
eq(out:level(), 1, "ending on table 00, every index black")
local r, g, b = out:blend(1, 1, 1)
eq(r + g + b, 0, "so the surround under it is black too")
-- IncGradGBPalTable_00 .. _03: black first, loaded palette last.
local into = IntroFade.new("inBlack")
eq(into:level(), 1, "inBlack opens on table 00, fully black")
for _ = 1, 32 do into:tick() end
eq(into:level(), 0, "and lands back on the loaded palette")
-- IncGradGBPalTable_05 .. _07.
local white = IntroFade.new("outWhite")
eq(white:level(), 1 / 3, "outWhite opens on table 05")
for _ = 1, 24 do white:tick() end
eq(white:level(), 1, "and ends on table 07, every index white")
local wr, wg, wb = white:blend(0, 0, 0)
eq(wr + wg + wb, 3, "with a white surround")
end
-- ---------------------------------------------------- NEW GAME -> the clock
do
-- Gold and Silver: NewGame_ClearTilemapEtc has already blanked the screen,
-- so InitClock's RotateFourPalettesLeft fades a cleared tilemap out.
local clock = InitClock.new({ input = fakeInput() },
{ save = {}, fades = true })
check(clock.blank, "the leading fade runs over a cleared tilemap")
check(IntroFade.busy(clock), "and it is a fade, not the clock screen")
eq(clock.fade.kind, "outBlack", "RotateFourPalettesLeft first")
for _ = 1, 32 do clock:update(0) end
eq(clock.fade and clock.fade.kind, "inBlack",
"then RotateFourPalettesRight into the clock")
check(not clock.blank, "which is the clock screen, not the cleared tilemap")
eq(clock.phase, "intro", "and no input reached it while it was fading")
for _ = 1, 32 do clock:update(0) end
check(not IntroFade.busy(clock), "64 frames covers both rotations")
-- Crystal: the gender screen carried RotateFourPalettesLeft out with it.
local after = InitClock.new({ input = fakeInput() },
{ save = {}, fades = true, faded = true })
eq(after.fade and after.fade.kind, "inBlack",
"so the clock opens straight on RotateFourPalettesRight")
check(not after.blank, "with the clock screen already under it")
-- The day wheel and the driver path never fade.
local day = InitClock.new({}, { save = {}, mode = "day", fades = true })
check(not IntroFade.busy(day), "SetDayOfWeek is not InitClock")
local driven = InitClock.new({}, { save = {}, autoConfirm = true,
fades = true })
check(not IntroFade.busy(driven), "and autoConfirm keeps its frame counts")
local plain = InitClock.new({ input = fakeInput() }, { save = {} })
check(not IntroFade.busy(plain), "a screen opened without fades has none")
end
-- ------------------------------------------------- the clock's own way out
do
local input = fakeInput()
local done = false
local clock = InitClock.new({ input = input },
{ save = {}, fades = true, faded = true,
onDone = function() done = true end })
for _ = 1, 32 do clock:update(0) end
clock.phase = "response"
clock.page = 1
clock:startText()
clock.typer.shown = clock.typer.total
input:press("a")
clock:update(0)
check(not done, "A on the last page does not hand over on the same frame")
eq(clock.fade and clock.fade.kind, "outBlack",
"OakSpeech's RotateFourPalettesLeft runs on the clock screen")
eq(clock.typer.shown, clock.typer.total,
"with Oak's answer still printed, not reprinted into an empty box")
for _ = 1, 31 do clock:update(0) end
check(not done, "for the whole 32 frames")
clock:update(0)
check(done, "and only then is InitClock done")
end
-- ------------------------------------------- the gender screen's way out
do
local chosen = nil
local gender = GenderSelect.new({}, {
save = { player = {} }, fades = true,
onDone = function(g) chosen = g end,
})
gender:choose(2)
for _ = 1, 10 do gender:update(0) end
eq(chosen, nil, "the 10-frame DelayFrames does not hand over on its own")
eq(gender.fade and gender.fade.kind, "outBlack",
"InitClock's fade runs while the answered gender screen is still up")
for _ = 1, 31 do gender:update(0) end
eq(chosen, nil, "for the whole rotation")
gender:update(0)
eq(chosen, "female", "and the answer follows the black screen")
local quick = nil
local plain = GenderSelect.new({}, {
save = { player = {} }, onDone = function(g) quick = g end })
plain:choose(1)
for _ = 1, 10 do plain:update(0) end
eq(quick, "male", "a screen opened without fades keeps its old timing")
end
-- ---------------------------------------------------- the shrink timeline
do
eq(OakSpeech.SHRINK_MUSIC_FADE, 32,
"ld a, 32 / ld [wMusicFade] -- and it is set before the SFX")
eq(OakSpeech.SHRINK_PIC1, 8, "DelayFrames 8, then Shrink1Pic")
eq(OakSpeech.SHRINK_PIC2, 16, "DelayFrames 8, then Shrink2Pic")
eq(OakSpeech.SHRINK_CLEAR, 24, "DelayFrames 8, then ClearBox 6,5 7x7")
eq(OakSpeech.SHRINK_ICON, 27,
"DelayFrames 3, then Intro_PlacePlayerSprite")
eq(OakSpeech.SHRINK_END, 77, "DelayFrames 50, then RotateThreePalettesRight")
local speech = setmetatable({}, OakSpeech)
speech.shrinkPic1, speech.shrinkPic2 = "pic1", "pic2"
speech.shrink = { frame = 0 }
speech.shrinkText = { "I'll be seeing you" }
speech.answers = {}
local placed = 0
speech.overworldIcon = function()
placed = placed + 1
return { image = false }
end
local finished = false
speech.onDone = function() finished = true end
for _ = 1, OakSpeech.SHRINK_CLEAR do speech:update(0) end
eq(speech.pic, nil, "ClearBox takes the shrink frame down at 24")
eq(speech.playerIcon, nil, "and nothing replaces it yet")
for _ = 1, OakSpeech.SHRINK_ICON - OakSpeech.SHRINK_CLEAR do
speech:update(0)
end
eq(placed, 1, "Intro_PlacePlayerSprite runs once, at frame 27")
check(speech.playerIcon ~= nil, "and the icon is what is on the screen")
for _ = 1, OakSpeech.SHRINK_END - OakSpeech.SHRINK_ICON do speech:update(0) end
eq(speech.shrink, nil, "the 50-frame hold ends at 77")
eq(speech.fade and speech.fade.kind, "outWhite",
"on RotateThreePalettesRight, not on the world load")
check(speech.playerIcon ~= nil, "with the icon still up through the fade")
check(not finished, "and the speech is not over yet")
for _ = 1, 24 do speech:update(0) end
check(finished, "24 frames later it is")
eq(speech.playerIcon, nil, "ClearTilemap takes the icon with it")
eq(speech.shrinkText, nil, "and the line under it")
end
@@ -0,0 +1,77 @@
-- engine/overworld/player_movement.asm:807-816
-- engine/overworld/events.asm:494-510
-- engine/overworld/map_objects.asm:1851-1860
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local Player = require("src.world.gen2.Player")
local World = require("src.world.gen2.World")
local MAP = {
inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end,
isWalkable = function(_, x, y) return x == 5 and y == 5 end,
}
local function bumpedPlayer()
local p = Player.new(5, 5, "right", nil)
p.turnArmed = false
p.animClock = 16
p:tryMove("right", MAP, nil)
return p
end
do
local p = bumpedPlayer()
eq(p.bumpFrames, 1, "the wall armed the in-place bump")
eq(p:walkPhase(), 1, "and this clock draws the walking pose")
p:stopForEvent()
eq(p.bumpFrames, nil, "stopForEvent drops the queued bump")
eq(p:walkPhase(), 0, "which snaps back to the standing pose")
eq(p:drawFlip(), p.stepFlip, "with the sprite unmirrored")
end
do
local peopleTicked = false
local w = setmetatable({
map = MAP,
player = bumpedPlayer(),
busy = function() return true end,
pollTimeOfDay = function() end,
pollCaveFlicker = function() end,
pollTileAnim = function() end,
handleCmdQueue = function() return false end,
updatePeople = function() peopleTicked = true end,
}, { __index = World })
eq(w.player:walkPhase(), 1, "the bump pose is live when the script starts")
w:stepBody()
check(peopleTicked, "the busy arm still ticks the map objects")
eq(w.player.bumpFrames, nil, "the held world cleared the bump")
eq(w.player:walkPhase(), 0, "so the talk draws the standing frame")
end
do
local w = setmetatable({
map = MAP,
player = Player.new(5, 5, "right", nil),
busy = function() return true end,
pollTimeOfDay = function() end,
pollCaveFlicker = function() end,
pollTileAnim = function() end,
handleCmdQueue = function() return false end,
updatePeople = function() end,
playerStepGrass = function() end,
grassAt = function() return false end,
}, { __index = World })
w.player:scriptStep("right")
local before = w.player.progress
w:stepBody()
check(w.player.progress > before, "a scripted step keeps advancing")
end
T.finish("gen2_talk_cancels_bump_bug2170")
@@ -54,7 +54,7 @@ eq(TextBox.ending("Hi"), nil, "and nothing for an untagged text")
local function newGame()
local game = {
save = { player = {}, options = { textSpeed = "FAST" } },
save = { player = {}, options = { textSpeed = "FAST" }, generation = 2 },
data = { text = {} },
}
game.stack = {
@@ -18,7 +18,7 @@ Sound.play = function() return nil end
local function newGame()
local game = {
save = { player = {}, options = { textSpeed = "FAST" } },
save = { player = {}, options = { textSpeed = "FAST" }, generation = 2 },
data = { text = {} },
}
game.stack = {
+182
View File
@@ -0,0 +1,182 @@
-- engine/events/pokemart.asm:212-219
-- engine/events/pokemart.asm:199-206
-- luajit tests/engine/mart_bag_full_bug2162.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local function glyphs(text)
local n = 0
for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
n = n + 1
end
return n
end
local realFont = package.loaded["src.render.Font"]
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function() end,
drawCode = function() end,
drawBox = function() end,
width = function(text) return glyphs(text) * 8 end,
split = function(text)
local out = {}
for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
out[#out + 1] = s
end
return out
end,
spansFitting = function(spans, pixels)
return math.min(#spans, math.floor(pixels / 8))
end,
encode = function(text) return { text } end,
advanceOf = function() return 8 end,
}
local RELOAD = {
"src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu",
"src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox",
}
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
local Bag = require("src.inventory.Bag")
local ShopMenu = require("src.ui.ShopMenu")
local TextBox = require("src.render.TextBox")
local BAG_FULL = "You can't carry\nany more items."
local NO_MONEY = "You don't have\nenough money."
local ANYTHING = "Is there anything\nelse I can do?"
local function boxText(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
local function newGame(money, ballCount)
local events, stack = {}, {}
local game
game = {
data = {
items = { POKE_BALL = { name = "POKe BALL", price = 200 } },
text = {
_PokemartItemBagFullText = BAG_FULL,
_PokemartNotEnoughMoneyText = NO_MONEY,
_PokemartAnythingElseText = ANYTHING,
},
constants = {},
},
save = { money = money, inventory = {}, bagOrder = {} },
}
if ballCount then
game.save.inventory.POKE_BALL = ballCount
game.save.bagOrder = { "POKE_BALL" }
end
game.stack = {
push = function(_, s)
events[#events + 1] = { "push", s }
stack[#stack + 1] = s
end,
pop = function()
local s = table.remove(stack)
events[#events + 1] = { "pop", s }
return s
end,
top = function() return stack[#stack] end,
}
return game, events, stack
end
local function buyOne(game, stack)
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
menu.items[1].onSelect()
local list = stack[#stack]
list.onChoose({ value = "POKE_BALL" })
local qty = stack[#stack]
game.stack:pop()
qty.onDone(1)
local confirm = stack[#stack]
game.stack:pop()
confirm.onChoose(true)
return menu, list
end
do
local game = newGame(3000, 99)
eq(Bag.add(game.save, "POKE_BALL", 1, game.data), false,
"AddItemToInventory refuses a 100th POKe BALL")
eq(game.save.inventory.POKE_BALL, 99, "and adds nothing")
end
for _, case in ipairs({
{ name = "bag full", money = 3000, count = 99, text = BAG_FULL },
{ name = "not enough money", money = 100, count = nil, text = NO_MONEY },
}) do
local game, events, stack = newGame(case.money, case.count)
local menu, list = buyOne(game, stack)
local box = stack[#stack]
if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then
check(boxText(box):find(case.text:gsub("\n", " "), 1, true) ~= nil,
case.name .. ": the clerk's refusal is the text")
end
eq(stack[#stack - 1], list, case.name .. ": the list is still under the box")
local popsBefore = 0
for _, ev in ipairs(events) do
if ev[1] == "push" and ev[2] == box then break end
if ev[1] == "pop" and ev[2] == list then popsBefore = popsBefore + 1 end
end
eq(popsBefore, 0,
case.name .. ": PrintText runs before LoadScreenTilesFromBuffer1")
eq(game.save.money, case.money, case.name .. ": no money changes hands")
game.stack:pop()
box.onDone()
eq(stack[#stack], nil, case.name .. ": A closes the list")
eq(menu.footer, ANYTHING, case.name .. ": the mart menu asks again")
eq(game.save.money, case.money, case.name .. ": money is still untouched")
end
do
-- pokemart.asm:113
local game, events, stack = newGame(3000)
game.data.items.BICYCLE = { name = "BICYCLE", price = 0, keyItem = true }
game.data.text._PokemartUnsellableItemText = "I can't put a\nprice on that."
game.save.inventory.BICYCLE = 1
game.save.bagOrder = { "BICYCLE" }
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
menu.items[2].onSelect()
local list = stack[#stack]
list.onChoose({ value = "BICYCLE" })
local box = stack[#stack]
check(getmetatable(box) == TextBox, "unsellable: a text box is pushed")
eq(stack[#stack - 1], list, "unsellable: the sell list is still under it")
game.stack:pop()
box.onDone()
eq(stack[#stack], nil, "unsellable: A closes the sell list")
eq(menu.footer, ANYTHING, "unsellable: the mart menu asks again")
end
do
-- the bag-empty refusal has no list of its own (pokemart.asm:50)
local game, _, stack = newGame(3000)
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
game.data.text._PokemartItemBagEmptyText = "You don't have\nanything to sell."
menu.items[2].onSelect()
local box = stack[#stack]
check(getmetatable(box) == TextBox, "empty bag: only a text box is pushed")
game.stack:pop()
box.onDone()
eq(menu.footer, ANYTHING, "empty bag: the mart menu asks again")
end
package.loaded["src.render.Font"] = realFont
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
require("src.ui.Screens").invalidate()
T.finish()

Some files were not shown because too many files have changed in this diff Show More