mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
Merge pull request #2184 from bryanthaboi/dev
fixes twixes and all around shlixes
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -316,6 +316,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
palette = "",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
battleLayout = "og",
|
||||
-- BATTLE SIZE (#1709): fixed | fill
|
||||
battleFit = "fixed",
|
||||
battleBg = "white",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) ---------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user