mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 09:54:43 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2a86cd258 | |||
| 8b56ec2d77 | |||
| f11724f08a | |||
| bd3e02986d | |||
| eed246f645 | |||
| c9d06779d8 | |||
| 48d8a4e922 | |||
| 9a05de4474 | |||
| 5c833a3d73 | |||
| 5051cb902d | |||
| 2720bfb5d8 | |||
| 54ba0f04ff | |||
| 3a0a72102f | |||
| fe31293566 | |||
| c8eeeaa1f6 | |||
| 273350d11e |
+19
-12
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
{ "jump_if_true", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
|
||||
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
|
||||
-- battle screen -- scripts/PokemonTower2F.asm:145-150
|
||||
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 },
|
||||
{ "jump_if_false", "end" }, -- loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
-- the win re-runs DisplayTextID on the beaten branch
|
||||
-- scripts/PokemonTower2F.asm:72-75, :137-140
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||
{ "walk_npc", 1, exitDirs },
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
|
||||
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
|
||||
{ "jump", "end" },
|
||||
{ "label", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
+20
-21
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
|
||||
local rows = {
|
||||
{ "play_cry", "MAROWAK" },
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
|
||||
ow:pushBattle(battle)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
or "I hope we meet\nagain..."
|
||||
game.stack:push(TextBox.new(game, impressed, function()
|
||||
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
|
||||
-- PrintEndBattleText prints it on the battle screen (#1817)
|
||||
battle.endBattleText = TextBox.substitute(game, cannotBe)
|
||||
battle.onFinish = function(result)
|
||||
if result ~= "win" then
|
||||
ow:afterBattle(result, battle)
|
||||
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" })
|
||||
| `catchRate` | integer 0..255 | yes |
|
||||
| `cry` | cries id | no |
|
||||
| `dex` | integer >= 1 | yes |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no |
|
||||
| `evolutions` | list of {item?, level?, method, species} | yes |
|
||||
| `frontSize` | integer 1..7 | yes |
|
||||
| `growthRate` | growth_rates id | yes |
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.29",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366635,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.29/gen1recomp++-0.2.29-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.2.28",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366087,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.28/gen1recomp++-0.2.28-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.27",
|
||||
"date": "2026-08-26",
|
||||
|
||||
@@ -104,7 +104,9 @@ local SE_FRAMES = {
|
||||
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
|
||||
SE_TRANSFORM_MON = 4,
|
||||
SE_SUBSTITUTE_MON = 3,
|
||||
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
|
||||
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
|
||||
-- loop exits twice per displayed frame (animations.asm:1884-1903)
|
||||
SE_WAVY_SCREEN = 128,
|
||||
}
|
||||
|
||||
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
|
||||
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local transform = resolveTransform(sub.type, attackerIsPlayer)
|
||||
local first, last, dir = 1, #sub.blocks, 1
|
||||
if transform == "REVERSE" then first, last, dir = last, first, -1 end
|
||||
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
|
||||
-- and a 40-frame pause, then rewinds the same subanimation; the
|
||||
-- mode-4 frame blocks persist, so the resting ball stays visible
|
||||
-- through the pauses between wobbles
|
||||
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
|
||||
local pendingTink = false
|
||||
for _ = 1, (opts and opts.shakes) or 1 do
|
||||
if opts and opts.shakes then
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
pendingTink = opts and opts.shakes ~= nil
|
||||
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
|
||||
local nblocks = math.abs(last - first) + 1
|
||||
local played = 0
|
||||
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
-- DoSpecialEffectByAnimationId runs after every frame
|
||||
-- block with wSubAnimCounter = blocks remaining
|
||||
played = played + 1
|
||||
if pendingTink then
|
||||
pendingTink = false
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
if ballFlicker then obp0Flip = not obp0Flip end
|
||||
if idFx then
|
||||
local counter = nblocks - played + 1
|
||||
|
||||
+43
-10
@@ -340,6 +340,22 @@ local function monPalette(data, species)
|
||||
return { name = name, colors = colors }
|
||||
end
|
||||
|
||||
-- MarowakAnim OBJ pics under OAM_PAL1:
|
||||
-- engine/battle/ghost_marowak_anim.asm:3-5,77
|
||||
local function objPicPalette()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
if not PaletteFX.usesSpriteObp() then return nil end
|
||||
local colors, group = PaletteFX.ogObj()
|
||||
if not colors then return nil end
|
||||
return { name = "obp1:" .. tostring(group), colors = colors }
|
||||
end
|
||||
|
||||
local function objPic(path, trueColor)
|
||||
local pal = objPicPalette()
|
||||
if not pal then return nil end
|
||||
return getImage(path, pal, trueColor)
|
||||
end
|
||||
|
||||
-- a named palette from the active COLORS pack as a getImage pal
|
||||
local function namedPalette(data, name)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -897,7 +913,8 @@ local function disguiseAsGhost(self)
|
||||
self.enemy.name = "GHOST"
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
|
||||
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
|
||||
end
|
||||
|
||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||
@@ -941,7 +958,11 @@ function BattleState:queueScopeReveal()
|
||||
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||
self:say(unveiled
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
self:act(function()
|
||||
self.ghostReveal = { t = 0 }
|
||||
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
|
||||
if ghostObj then self.enemy.sprite = ghostObj end
|
||||
end)
|
||||
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
|
||||
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||
@@ -3264,9 +3285,9 @@ function BattleState:applyAnimEffect(ev)
|
||||
end
|
||||
fx.hudShakeProg = prog
|
||||
elseif e == "SE_WAVY_SCREEN" then
|
||||
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
|
||||
-- walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 255, phase = 0 }
|
||||
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
|
||||
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 128, phase = 0 }
|
||||
|
||||
-- ---------------------------------------------- mon pic effects
|
||||
elseif e == "SE_SLIDE_MON_OFF" then
|
||||
@@ -3650,7 +3671,7 @@ function BattleState:updateFx()
|
||||
end
|
||||
if fx.wavy then
|
||||
fx.wavy.left = fx.wavy.left - 1
|
||||
fx.wavy.phase = fx.wavy.phase + 1
|
||||
fx.wavy.phase = fx.wavy.phase + 2
|
||||
if fx.wavy.left <= 0 then fx.wavy = nil end
|
||||
end
|
||||
end
|
||||
@@ -3704,12 +3725,22 @@ function BattleState:updateFx()
|
||||
local real = self.ghostReal
|
||||
if real then
|
||||
self.enemy.name = real.name or self.enemy.name
|
||||
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||
gr.bgSprite = real.sprite or self.enemy.sprite
|
||||
local objReal
|
||||
if objPicPalette() then
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
|
||||
"front", { mon = self.enemy.mon, kind = "battle" })
|
||||
objReal = path and objPic(path, tc)
|
||||
end
|
||||
self.enemy.sprite = objReal or gr.bgSprite
|
||||
end
|
||||
end
|
||||
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||
end
|
||||
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||
-- home/clear_sprites.asm:1
|
||||
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
|
||||
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||
end
|
||||
end
|
||||
@@ -3825,6 +3856,9 @@ function BattleState:executeAction(user, target, action)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
previous = previous,
|
||||
@@ -5950,8 +5984,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
|
||||
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
|
||||
-1, -1, -1 }
|
||||
|
||||
-- wave the BG canvas one scanline at a time; the offset table walks
|
||||
-- one entry per frame like the asm's advancing pointer
|
||||
-- WavyScreen_SetSCX: animations.asm:1916-1927
|
||||
function BattleState:applyWavy(src)
|
||||
local wavy = self.fx and self.fx.wavy
|
||||
if not wavy then return src end
|
||||
@@ -5964,7 +5997,7 @@ function BattleState:applyWavy(src)
|
||||
for line = 0, 143 do
|
||||
self.waveQuad:setViewport(0, line, 160, 1)
|
||||
g.draw(src, self.waveQuad,
|
||||
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
|
||||
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
|
||||
end
|
||||
g.setCanvas(prev)
|
||||
return self.waveCanvas
|
||||
|
||||
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
|
||||
}
|
||||
self.objects = AnimObjects.new(self.data, self.constants, self.env)
|
||||
self.bg = BgEffects.new(self.constants, self.env)
|
||||
self.animId = opts.animId -- wFXAnimID
|
||||
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
|
||||
self.sfxOrder = opts.sfxOrder or {}
|
||||
|
||||
|
||||
+8
-2
@@ -186,7 +186,8 @@ function Game:makeTitleState()
|
||||
onContinue = function()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
self:restoreSave(loaded, recovered,
|
||||
{ freshBoot = true, continued = true })
|
||||
end
|
||||
end,
|
||||
onExit = self.onExit,
|
||||
@@ -1322,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
report.recovered = recovered
|
||||
report.modsDiff = modsDiff
|
||||
self.save = loaded
|
||||
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
|
||||
loaded.startMenuIndex = nil
|
||||
self.startMenuIndex = nil
|
||||
self:adoptSave(loaded)
|
||||
-- SaveData.load already attached the standalone options.lua table
|
||||
self:applyOptions(loaded.options)
|
||||
@@ -1336,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||
-- crossfade by default.
|
||||
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
|
||||
local facing = (opts and opts.continued) and "down" or loaded.player.facing
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
loaded.player.x, loaded.player.y, facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
|
||||
@@ -1024,6 +1024,11 @@ function Game2:load()
|
||||
require("src.core.gen2.Phone").useRegistry(self.data)
|
||||
require("src.core.gen2.Decorations").useRegistry(self.data)
|
||||
require("src.core.gen2.Apricorns").useRegistry(self.data)
|
||||
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
|
||||
-- merge target (data.pokemon): a translation mod's
|
||||
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
|
||||
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
|
||||
require("src.core.gen2.PokedexText").apply(self.data)
|
||||
|
||||
-- Rendering pipelines: the engine half of the render_pipelines registry
|
||||
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
|
||||
|
||||
@@ -40,8 +40,10 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
|
||||
|
||||
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
|
||||
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
|
||||
[1] = "a", [2] = "b",
|
||||
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
|
||||
[5] = "leftshoulder", [6] = "rightshoulder",
|
||||
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
|
||||
[11] = "triggerleft", [12] = "triggerright",
|
||||
}
|
||||
|
||||
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
|
||||
|
||||
@@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where)
|
||||
hatched.experience = egg.experience
|
||||
-- `ld a, [de] / ld [hli], a` twice: HP := MaxHP.
|
||||
hatched.hp = hatched.maxHp
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
-- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its
|
||||
-- table id do not survive -- engine/pokemon/breeding.asm:299-309
|
||||
hatched.ot = (save.player and save.player.name) or egg.ot
|
||||
hatched.otId = (save.player and save.player.id) or egg.otId
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own
|
||||
-- data table.
|
||||
--
|
||||
-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua,
|
||||
-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua
|
||||
-- actually reads for the KIND label and the two description pages
|
||||
-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from
|
||||
-- disk in Game2:load, BEFORE mods:load runs, and never routed through a
|
||||
-- registry. The `pokemon` registry's own merge target is the separate
|
||||
-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`),
|
||||
-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ...,
|
||||
-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was
|
||||
-- silently invisible in-game despite validating against the registry.
|
||||
--
|
||||
-- This is the missing link: called once after the merge (src/core/Game2.lua),
|
||||
-- the same way ItemEffects.applyHeldItems projects held_items onto
|
||||
-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not
|
||||
-- text a translation carries.
|
||||
local PokedexText = {}
|
||||
|
||||
function PokedexText.apply(data)
|
||||
local dex = data and data.gen2Pokedex
|
||||
local pokemon = data and data.pokemon
|
||||
if not (dex and dex.entries and pokemon) then return 0 end
|
||||
local count = 0
|
||||
for species, entry in pairs(dex.entries) do
|
||||
local def = pokemon[species]
|
||||
local override = def and def.dexEntry
|
||||
if override and (override.kind or override.text or override.text2) then
|
||||
if override.kind then entry.kind = override.kind end
|
||||
if override.text then entry.text = override.text end
|
||||
if override.text2 then entry.text2 = override.text2 end
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return PokedexText
|
||||
@@ -1394,7 +1394,9 @@ local function buildHeader(imp, m)
|
||||
-- under the gear and the quit X -- "the settings is covering the logo".
|
||||
-- Reserving the space on both sides costs a little width and cannot
|
||||
-- overlap at any window size.
|
||||
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
|
||||
-- iOS has no quit button (the OS owns app exit), so the cluster is the
|
||||
-- gear alone and the wordmark gets that width back
|
||||
local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad
|
||||
local boxX = m.x + clusterW
|
||||
local boxW = math.max(0, m.w - 2 * clusterW)
|
||||
if imp.logo and boxW > 0 then
|
||||
@@ -1428,8 +1430,8 @@ local function buildHeader(imp, m)
|
||||
-- inboard of it -- but the two are REGISTERED gear first, because the first
|
||||
-- focusable of the first frame adopts the keyboard ring and that must not be
|
||||
-- the button that exits the app.
|
||||
local quitX = rx - gear
|
||||
rx = quitX - math.floor(6 * m.s)
|
||||
local quitX = not imp.ios and rx - gear or nil
|
||||
if quitX then rx = quitX - math.floor(6 * m.s) end
|
||||
|
||||
-- Settings gear. It now also owns the CONTROL settings (touch overlay
|
||||
-- editor, reset rebinds), which used to be buttons stacked in the game
|
||||
@@ -1441,7 +1443,7 @@ local function buildHeader(imp, m)
|
||||
chrome.gear.image = imp._gearIcon
|
||||
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
|
||||
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
|
||||
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
|
||||
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
|
||||
local speciesOrder = self.manifest.constants.speciesOrder or {}
|
||||
local cries = {}
|
||||
for index, species in ipairs(speciesOrder) do
|
||||
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
|
||||
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
|
||||
if not tostring(species):match("^UNUSED") then
|
||||
local row = self.rom:bytes(
|
||||
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
|
||||
local cryIndex = row[1] + row[2] * 256
|
||||
|
||||
@@ -3193,8 +3193,13 @@ function RomImporter:gamepadpressed(_, button)
|
||||
return
|
||||
end
|
||||
|
||||
-- Select button: toggle Virtual Keyboard
|
||||
-- Select button: toggle Virtual Keyboard on handhelds
|
||||
if button == "back" or button == "select" or action == "select" then
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
|
||||
if not isHandheld then return end
|
||||
if okKit and Kit.VirtualKeyboard then
|
||||
if Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.close(false)
|
||||
@@ -3277,6 +3282,17 @@ function RomImporter:gamepadpressed(_, button)
|
||||
return
|
||||
end
|
||||
|
||||
-- Triggers (L2 / R2): fast scroll content / lists up and down
|
||||
if button == "triggerleft" or button == "lefttrigger" or button == "l2"
|
||||
or action == "triggerleft" or action == "l2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
return
|
||||
elseif button == "triggerright" or button == "righttrigger" or button == "r2"
|
||||
or action == "triggerright" or action == "r2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
return
|
||||
end
|
||||
|
||||
-- Start button: Play / Choose ROM
|
||||
if button == "start" then
|
||||
if self.workState == "working" then return end
|
||||
@@ -3353,6 +3369,24 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
elseif axis == "triggerleft" or axis == "lefttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._l2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2TriggerActive = false
|
||||
end
|
||||
elseif axis == "triggerright" or axis == "righttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._r2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2TriggerActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3380,6 +3414,24 @@ function RomImporter:joystickaxis(joystick, axis, value)
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
elseif axis == 3 or axis == 5 then
|
||||
if value > 0.4 then
|
||||
if not self._l2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2RawActive = false
|
||||
end
|
||||
elseif axis == 4 or axis == 6 then
|
||||
if value > 0.4 then
|
||||
if not self._r2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2RawActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
-- The GAME decides before the BYTES do. Everything below this line judges a
|
||||
-- save by Gen 1's rules -- the size test, and mainChecksumValid, which is
|
||||
-- pokered's checksum -- so a Gen 2 cart save reaching it is measured against
|
||||
-- a rule that cannot match and comes back "checksum invalid" (#1832).
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(version)
|
||||
if not supported then return false, unsupportedWhy end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
local check = SaveConvert.mainChecksumValid(bytes)
|
||||
if check == nil then
|
||||
|
||||
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
|
||||
end
|
||||
|
||||
-- .useRareCandy prints over the still-drawn party menu
|
||||
-- (engine/items/item_effects.asm:1392-1418)
|
||||
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
|
||||
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
|
||||
function ItemEffects.keepsPartyMenuOpen(id)
|
||||
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||
or VITAMINS[id] ~= nil
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
@@ -526,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- _VitaminStatRoseText's slot order is localization-dependent (the
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
-- engine/items/item_effects.asm:1313
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
|
||||
+26
-4
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
|
||||
-- replacement shares sendOutPlayer and prints neither line
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
theirName, s.enemy.name))
|
||||
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
|
||||
if hostMsg.kind == "switch" then
|
||||
local idx = hostMsg.index
|
||||
s:act(function() sendOutHost(s, hostParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
if guestMsg.kind == "switch" then
|
||||
local idx = guestMsg.index
|
||||
s:act(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
guestName, s.enemy.name))
|
||||
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
|
||||
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
|
||||
function overrides.new(game)
|
||||
local g = live() or game
|
||||
local save = g and g.save
|
||||
if save and save.startMenuIndex then
|
||||
Start2.lastIndex = save.startMenuIndex
|
||||
if g and g.startMenuIndex then
|
||||
Start2.lastIndex = g.startMenuIndex
|
||||
end
|
||||
return newOrig(g, {
|
||||
save = save,
|
||||
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
|
||||
new = "synthesises onClose (stack:pop) and onChoose "
|
||||
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
|
||||
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
|
||||
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
.. "and never copied back: Gold's cursor is a class field, not save data",
|
||||
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
|
||||
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
|
||||
|
||||
@@ -826,10 +826,14 @@ R.pokemon = {
|
||||
item = f.opt(f.id("items")),
|
||||
species = f.id("pokemon") }),
|
||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||
-- text2 is the #DEX entry's second description page (Pokedex_asm's bare
|
||||
-- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody
|
||||
-- shows `entry.text` on page 1 and `entry.text2` on page 2, so a
|
||||
-- translation needs both to cover the whole entry.
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
text = f.str, text2 = f.opt(f.str) }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||
|
||||
@@ -332,6 +332,7 @@ end
|
||||
|
||||
-- transparent: the world pass shows through (UI pass draws overlays only)
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.uiOpaque = not transparent
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
@@ -699,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
|
||||
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
|
||||
local first = zoneList[1]
|
||||
if canvas == self.canvas and self.uiOpaque and first.colors
|
||||
and not (bx + first.x * zoneSx <= boxX
|
||||
and by + first.y * zoneSy <= boxY
|
||||
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
|
||||
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
|
||||
PaletteFX.sendColors(shader, first.colors)
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
|
||||
@@ -28,6 +28,52 @@ local MAX_COLS = 18
|
||||
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
|
||||
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
|
||||
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
|
||||
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
|
||||
TextBox.PAUSE = "\1"
|
||||
local PAUSE_FRAMES = 30
|
||||
|
||||
local function glyphs(str)
|
||||
return #Font.split((str:gsub("[\n\v\f]", "")))
|
||||
end
|
||||
|
||||
local function stripPauses(text)
|
||||
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
|
||||
local out, marks, count, pos = {}, {}, 0, 1
|
||||
while true do
|
||||
local i = text:find(TextBox.PAUSE, pos, true)
|
||||
if not i then
|
||||
out[#out + 1] = text:sub(pos)
|
||||
break
|
||||
end
|
||||
local chunk = text:sub(pos, i - 1)
|
||||
out[#out + 1] = chunk
|
||||
count = count + glyphs(chunk)
|
||||
marks[#marks + 1] = count
|
||||
pos = i + 1
|
||||
end
|
||||
return table.concat(out), marks
|
||||
end
|
||||
|
||||
-- glyph offsets into the whole text -> [page][line][char]
|
||||
local function mapPauses(pages, marks)
|
||||
local at, acc, mi = {}, 0, 1
|
||||
for pi, page in ipairs(pages) do
|
||||
for li, line in ipairs(page) do
|
||||
local n = #Font.split(line)
|
||||
while mi <= #marks and marks[mi] <= acc + n do
|
||||
local ci = marks[mi] - acc
|
||||
at[pi] = at[pi] or {}
|
||||
at[pi][li] = at[pi][li] or {}
|
||||
at[pi][li][ci] = mi
|
||||
mi = mi + 1
|
||||
end
|
||||
acc = acc + n
|
||||
end
|
||||
end
|
||||
return at
|
||||
end
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
-- the box then closes and choice(yes) runs instead of onDone.
|
||||
@@ -66,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.stay = opts and opts.stay
|
||||
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
|
||||
self.preSound = opts and opts.preSound
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
self.sfxWait = opts and opts.sfxWait
|
||||
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
||||
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
||||
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
||||
@@ -81,7 +129,13 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.line1Y = (self.boxTy + 2) * 8
|
||||
self.line2Y = (self.boxTy + 4) * 8
|
||||
text = TextBox.substitute(game, text)
|
||||
local marks
|
||||
text, marks = stripPauses(text)
|
||||
self.pages = TextBox.paginate(text, self.maxCols)
|
||||
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
|
||||
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
|
||||
self.pauseSounds = opts and opts.pauseSounds
|
||||
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
|
||||
self.pageIndex = 1
|
||||
self.lineIndex = 1
|
||||
self.charIndex = 0
|
||||
@@ -276,6 +330,14 @@ function TextBox:visibleText()
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
|
||||
function TextBox:sfxHeld()
|
||||
if not self.sfxWait then return false end
|
||||
if require("src.core.Sound").sfxBusy() then return true end
|
||||
self.sfxWait = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
@@ -297,6 +359,20 @@ function TextBox:update(dt)
|
||||
self.holdFrames = self.holdFrames - 1
|
||||
return
|
||||
end
|
||||
-- home/text.asm:492
|
||||
if self.pauseFrames then
|
||||
if self.pauseFrames > 0 then
|
||||
self.pauseFrames = self.pauseFrames - 1
|
||||
return
|
||||
end
|
||||
self.pauseFrames = nil
|
||||
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
|
||||
if type(snd) == "function" then
|
||||
snd()
|
||||
elseif snd then
|
||||
require("src.core.Sound").play(self.game.data, snd)
|
||||
end
|
||||
end
|
||||
if self.done then
|
||||
-- opts.stay: the box is finished but stays up under whatever the caller
|
||||
-- pushed over it; StateStack updates the top state only, so this runs
|
||||
@@ -378,6 +454,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -393,6 +470,7 @@ function TextBox:update(dt)
|
||||
self.preWait = self.preWait - 1
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
@@ -430,6 +508,15 @@ function TextBox:update(dt)
|
||||
self.charIndex = self.charIndex + 1
|
||||
local line = self.shown[#self.shown]
|
||||
line[#line + 1] = self.codes[self.charIndex]
|
||||
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
|
||||
marks = marks and marks[self.lineIndex]
|
||||
if marks and marks[self.charIndex] then
|
||||
self.pauseMark = marks[self.charIndex]
|
||||
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
|
||||
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
|
||||
and 0 or PAUSE_FRAMES
|
||||
break
|
||||
end
|
||||
else
|
||||
-- line finished
|
||||
local page = self.pages[self.pageIndex]
|
||||
|
||||
@@ -150,13 +150,10 @@ end
|
||||
|
||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||
-- means draw nothing extra (the static window tile shows through,
|
||||
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||
-- direct equivalent discrete step counter.
|
||||
-- matching the asm's restore-to-original behavior).
|
||||
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
|
||||
function TileRenderer.spinBlurActive()
|
||||
return spinning and (math.floor(animFrame / 8) % 2 == 0)
|
||||
return spinning and (math.floor(animFrame / 16) % 2 == 0)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
@@ -235,6 +235,35 @@ local function gen2CartName(gameVersion)
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
end
|
||||
|
||||
-- Can a cart save for this game cross in or out at all? Public because the
|
||||
-- launcher has to ask about the GAME before it measures the BYTES.
|
||||
--
|
||||
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
|
||||
-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is
|
||||
-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is
|
||||
-- 32786 bytes: it misses the size test and is then measured against a rule
|
||||
-- that was never going to match it. The player is told "save data checksum
|
||||
-- invalid" about a save that is perfectly good (#1832).
|
||||
--
|
||||
-- Returns true, or false plus the same sentence importSav/exportSav would have
|
||||
-- answered with, so a caller that asks early and a caller that does not cannot
|
||||
-- describe the same game two different ways.
|
||||
function SaveConvert.importSupported(gameVersion)
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveConvert.exportSupported(gameVersion)
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||
@@ -247,10 +276,8 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
end
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
@@ -283,10 +310,8 @@ function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
|
||||
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
|
||||
end
|
||||
|
||||
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
|
||||
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
|
||||
-- port owns as World:playWhirlpoolSound. #1717
|
||||
-- OWCutAnimation, and the block only reaches the screen after the sound
|
||||
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
|
||||
function H.DisappearWhirlpool(ctx)
|
||||
local ret = H.CutDownTreeOrGrass(ctx)
|
||||
call(ctx, "playWhirlpoolSound")
|
||||
return ret
|
||||
local index = ctx and ctx.cutWhirlpoolBlockIndex
|
||||
local blockId = ctx and ctx.cutWhirlpoolReplacement
|
||||
call(ctx, "playWhirlpoolSound", index, blockId)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
|
||||
|
||||
@@ -152,6 +152,12 @@ local function answer(vm, value)
|
||||
vm.scriptVar = value or 0
|
||||
end
|
||||
|
||||
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
|
||||
-- the coroutine has no sfx to drain.
|
||||
local function drainSfx()
|
||||
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
|
||||
end
|
||||
|
||||
-- Every routine that ends `call GetPokemonName / jp
|
||||
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
|
||||
-- {STRBUF} will find it.
|
||||
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
|
||||
local what = (h.monName and h.monName(entry.species)) or entry.species
|
||||
or ""
|
||||
vm:showRaw(Strings(row.page, who, what))
|
||||
-- pokegold engine/events/bug_contest/judging.asm:29-32
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(row.sfx) end
|
||||
vm:showRaw(Strings(row.score, entry.score or 0))
|
||||
vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
|
||||
end
|
||||
end
|
||||
answer(vm, place)
|
||||
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
|
||||
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
|
||||
local rating = findOakRating(caught)
|
||||
local h = hooks(vm)
|
||||
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
|
||||
vm:showRaw(Strings(rating.text))
|
||||
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
|
||||
-- playing under the rating text, and the caller (OaksLab's own script)
|
||||
-- waits it out before closing the box.
|
||||
coroutine.yield({ kind = "waitsfx" })
|
||||
vm:showRaw(Strings(rating.text), nil, nil, true)
|
||||
end
|
||||
|
||||
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
|
||||
|
||||
+10
-6
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
|
||||
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
|
||||
-- name their own pocket, not the ITEM one (data/text/common_2.asm
|
||||
-- :1351, data/items/pocket_names.asm:10-13).
|
||||
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
|
||||
-- its press until the jingle ends.
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
else
|
||||
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
|
||||
end
|
||||
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
|
||||
self.playSoundFn(SFX_ITEM)
|
||||
end
|
||||
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
|
||||
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
|
||||
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
|
||||
-- between them (engine/events/fruit_trees.asm:23-24);
|
||||
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
|
||||
-- The port used to park here on a `waitsfx`, which is the same seam
|
||||
-- GiveItemScript's did: this port's box takes its own button and pops on
|
||||
-- it, so the park ran with an EMPTY state stack and the bare overworld
|
||||
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
|
||||
-- noun still comes from ItemPocketNames rather than from a third copy of
|
||||
-- the literal (data/items/pocket_names.asm:10-13).
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
return "end"
|
||||
elseif op == "describedecoration" then
|
||||
-- `describedecoration byte` picks one of five DECODESC_* arms
|
||||
@@ -2427,7 +2429,7 @@ end
|
||||
-- the port's world does not tick while a box is on the stack (Game2:update
|
||||
-- stops at the top state), so the only clock that can count it is the box's;
|
||||
-- World:showText is where it lands.
|
||||
function Vm:showRaw(body, stay, hold)
|
||||
function Vm:showRaw(body, stay, hold, sfxWait)
|
||||
if not body or body == "" then body = "..." end
|
||||
if self.stringBuffer and self.stringBuffer ~= "" then
|
||||
body = body:gsub("{STRBUF}", self.stringBuffer)
|
||||
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
|
||||
text = body,
|
||||
stay = (stay or self:textStays()) and true or false,
|
||||
hold = hold,
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
sfxWait = sfxWait and true or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
|
||||
if req and req.kind == "text" and self.showTextFn then
|
||||
self.showTextFn(req.text, function()
|
||||
self:resume()
|
||||
end, req.stay, req.hold)
|
||||
end, req.stay, req.hold, req.sfxWait)
|
||||
elseif req and req.kind == "wait" then
|
||||
self.waitLeft = req.frames or 0
|
||||
elseif req and req.kind == "waitbutton" then
|
||||
|
||||
+3
-1
@@ -360,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
|
||||
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
|
||||
-- mashing A burns through a stack of them (#796)
|
||||
-- RareCandyText carries sound_get_item_1
|
||||
-- (engine/menus/party_menu.asm:289-293)
|
||||
showMessages(game, payload, function()
|
||||
local StatBox = require("src.battle.BattleState").StatBox
|
||||
game.stack:push(StatBox.new(game, target, function()
|
||||
@@ -400,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
nextStep()
|
||||
end))
|
||||
end)
|
||||
end, TextBox.soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
|
||||
+85
-41
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
local function monLabel(game, mon)
|
||||
-- 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)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
|
||||
return {
|
||||
label = mon.nickname or def.name,
|
||||
sub = Strings(":L%d", mon.level),
|
||||
value = value,
|
||||
}
|
||||
end
|
||||
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
local function withCancel(items)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
local function monName(game, mon)
|
||||
@@ -61,14 +73,15 @@ local function withdraw(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_withdraw",
|
||||
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
|
||||
-- visible around it (home/list_menu.asm:29-31)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
monSubmenu(game, "WITHDRAW", mon, function()
|
||||
@@ -111,12 +124,14 @@ local function deposit(game)
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(game.save.party) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
table.insert(items, monRow(game, mon, i))
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_deposit",
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
@@ -163,14 +178,13 @@ local function release(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_release",
|
||||
onChoose = function(_, list)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
@@ -202,37 +216,67 @@ local function release(game)
|
||||
}))
|
||||
end
|
||||
|
||||
local function changeBox(game)
|
||||
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
|
||||
local function changeBoxMenu(game)
|
||||
local boxes = Boxes.ensure(game.save)
|
||||
local items = {}
|
||||
for i = 1, Boxes.COUNT do
|
||||
local mark = i == game.save.currentBox and "*" or " "
|
||||
table.insert(items, {
|
||||
label = Strings("%sBOX %2d", mark, i),
|
||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||
value = i,
|
||||
})
|
||||
items[i] = {
|
||||
label = Strings("BOX%2d", i),
|
||||
onSelect = function()
|
||||
game.save.currentBox = i
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
end,
|
||||
}
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_change",
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
game.save.currentBox = item.value
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
list:close()
|
||||
end,
|
||||
}))
|
||||
local menu = Menu.new(game, items, {
|
||||
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
|
||||
})
|
||||
menu.kind = "pc_box_change" -- screen.render_visible identity
|
||||
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
|
||||
local t = game.data.text
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
|
||||
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
|
||||
Font.draw(line, 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
Font.drawBox(0, 0, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BOX No."), 8, 16)
|
||||
local n = game.save.currentBox or 1
|
||||
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
|
||||
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
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
|
||||
-- and returns to the PC menu on No.
|
||||
local function changeBox(game)
|
||||
local t = game.data.text
|
||||
local ask = TextBox.new(game, t._WhenYouChangeBoxText
|
||||
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
|
||||
nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if yes then changeBoxMenu(game) end
|
||||
end,
|
||||
}))
|
||||
})
|
||||
ask.kind = "pc_box_change"
|
||||
game.stack:push(ask)
|
||||
end
|
||||
|
||||
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
|
||||
|
||||
+37
-5
@@ -87,6 +87,15 @@ local function frameSheet(game)
|
||||
return frameCache[path]
|
||||
end
|
||||
|
||||
-- one tile of the sheet, for the CONTENTS screen's divider column
|
||||
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
|
||||
function DexEntryMenu.tile(game, code, tx, ty)
|
||||
local frame = frameSheet(game)
|
||||
if not (frame and code) then return end
|
||||
local quad = frame.quads[code - 0x60]
|
||||
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:601
|
||||
local DIVIDER = {
|
||||
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
self.page = 1
|
||||
self.blink = 0
|
||||
local pages = descPages(game, self.def, forceOwned)
|
||||
self.pageCount = pages and #pages or 1
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
|
||||
self.crySrc = require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
|
||||
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
|
||||
function DexEntryMenu:crying()
|
||||
local src = self.crySrc
|
||||
if not src then return false end
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
self.crySrc = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = ((self.blink or 0) + 1) % 60
|
||||
if self:crying() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:245
|
||||
if self.page < (self.pageCount or 1) then
|
||||
@@ -127,15 +150,18 @@ end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor, self.page)
|
||||
self.spriteTrueColor, self.page,
|
||||
{ crying = self:crying(),
|
||||
arrow = (self.blink or 0) < 30 })
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
-- engine/menus/pokedex.asm:399
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
|
||||
page = page or 1
|
||||
state = state or {}
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local frame = frameSheet(game)
|
||||
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||
16, 64)
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
-- 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
|
||||
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
for i, line in ipairs(lines) do
|
||||
Font.draw(line, 8, 72 + i * 16)
|
||||
end
|
||||
-- home/text.asm:245
|
||||
if page < #pages then
|
||||
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
|
||||
if page < #pages and state.arrow ~= false then
|
||||
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||
end
|
||||
else
|
||||
|
||||
+41
-26
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
-- the bag's item list: a partial box the map stays visible around, not a
|
||||
-- screen of its own (home/list_menu.asm:29-31)
|
||||
self.itemBox = opts.itemBox or false
|
||||
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
|
||||
-- caller gets the same box, the PC item lists included (#1845).
|
||||
self.itemBox = opts.itemBox or opts.messageBox or false
|
||||
if self.itemBox then
|
||||
self.isOpaque = false
|
||||
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||
@@ -256,6 +257,34 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
local function drawMessageBox(self)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.footer then return end
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
|
||||
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
|
||||
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
|
||||
function ListMenu.drawBall(x, y)
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.circle("fill", x, y, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", x, y, 1.2)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||
function ListMenu:drawItemBox()
|
||||
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
|
||||
if item.cancel then sawCancel = true end
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
if item.sub then
|
||||
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
|
||||
Font.draw(item.sub, ITEM_QTY_X, y + 8)
|
||||
elseif item.right then
|
||||
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||
-- (home/list_menu.asm:479-490)
|
||||
local count = item.right:sub(2)
|
||||
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
|
||||
if shown == self.rows and not sawCancel then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
|
||||
-- so the bottom box sits under the list from the first frame
|
||||
if self.messageBox or self.footer then drawMessageBox(self) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -326,13 +361,7 @@ function ListMenu:draw()
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", bx, by, 1.2)
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
@@ -360,22 +389,8 @@ function ListMenu:draw()
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
end
|
||||
if self.dialogue or (self.messageBox and self.footer) then
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
if self.dialogue then
|
||||
drawMessageBox(self)
|
||||
elseif self.footer then
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
|
||||
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
-- the "And..." tail. Each text_pause holds the box, and the first one
|
||||
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
|
||||
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_PoofText", " Poof!")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_ForgotAndText",
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
|
||||
+21
-5
@@ -71,6 +71,12 @@ function PartyMenu:sgbPalettes(game)
|
||||
return zones
|
||||
end
|
||||
|
||||
-- data/moves/field_moves.asm: leftmost tile per field move name
|
||||
local FIELD_MOVE_X = {
|
||||
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
|
||||
strength = 10, TELEPORT = 10, softboiled = 8,
|
||||
}
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
@@ -706,6 +712,9 @@ function PartyMenu:update(dt)
|
||||
-- (text_box.asm .donePrintingNames). #768
|
||||
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
|
||||
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
|
||||
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
|
||||
-- .exitMenu), where B returns to the party list. #1833
|
||||
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
|
||||
end
|
||||
local ctx = { battle = self.battle, overworld = ow }
|
||||
local hooked = Runtime.call("ui.party.submenu", sameItems,
|
||||
@@ -873,12 +882,19 @@ function PartyMenu:draw()
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
|
||||
local y0 = (17 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
|
||||
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
|
||||
local lx = 12
|
||||
for _, entry in ipairs(self.subItems) do
|
||||
local mx = FIELD_MOVE_X[entry.move or entry.action]
|
||||
if mx and mx < lx then lx = mx end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
|
||||
local top = n > 3 and math.max(0, 16 - n * 2) or 11
|
||||
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
|
||||
local y0 = (18 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
|
||||
end
|
||||
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+46
-3
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local PlayerPC = {}
|
||||
|
||||
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
|
||||
})
|
||||
end
|
||||
end
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
local function leftOnCancel(item, list)
|
||||
if item and item.cancel then
|
||||
list:close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
|
||||
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
|
||||
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
|
||||
@@ -76,11 +89,21 @@ end
|
||||
|
||||
local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:144-149: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_withdraw",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
|
||||
footer = romText(game.data, "_WhatToWithdrawText",
|
||||
"What do you want\nto withdraw?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
@@ -112,11 +135,21 @@ local function deposit(game)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
|
||||
local order = Bag.order(game.save, game.data)
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
|
||||
-- players_pc.asm:90-95: an empty bag never reaches the list
|
||||
if #order == 0 then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
|
||||
"You have nothing\nto deposit."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
|
||||
kind = "pc_item_deposit",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
|
||||
footer = romText(game.data, "_WhatToDepositText",
|
||||
"What do you want\nto deposit?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
@@ -134,11 +167,21 @@ end
|
||||
|
||||
local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:196-201: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_toss",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:205-206 WhatToTossText, printed before the list
|
||||
footer = romText(game.data, "_WhatToTossText",
|
||||
"What do you want\nto toss away?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
list.footer = Strings("That's too impor-\ntant to toss!")
|
||||
|
||||
+273
-82
@@ -1,9 +1,38 @@
|
||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local PokedexMenu = {}
|
||||
PokedexMenu.__index = PokedexMenu
|
||||
PokedexMenu.isOpaque = true
|
||||
|
||||
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
|
||||
local ROWS = 7
|
||||
local FIRST_ROW_Y = 24
|
||||
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
|
||||
|
||||
-- engine/menus/pokedex.asm:163-199
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
|
||||
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
|
||||
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
|
||||
|
||||
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
|
||||
-- toggling to $70 down each nine-tile run, both runs restarting on $71
|
||||
local function dividerCodes()
|
||||
local codes = { [0] = 0x71 }
|
||||
for _, top in ipairs({ 1, 9 }) do
|
||||
local code = 0x71
|
||||
for i = 0, 8 do
|
||||
codes[top + i] = code
|
||||
code = code == 0x71 and 0x70 or 0x71
|
||||
end
|
||||
end
|
||||
return codes
|
||||
end
|
||||
local DIVIDER = dividerCodes()
|
||||
|
||||
-- SGB: PalPacket_Pokedex, whole screen
|
||||
function PokedexMenu:sgbPalettes(game)
|
||||
@@ -12,108 +41,270 @@ end
|
||||
|
||||
function PokedexMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PokedexMenu)
|
||||
self.game = game
|
||||
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
|
||||
self.index = 1
|
||||
self.scroll = 0
|
||||
self.rowsAt = require("src.core.GameVersion").isYellow()
|
||||
and YELLOW_ROWS or RED_ROWS
|
||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
local byDex = {}
|
||||
for species, def in pairs(game.data.pokemon) do
|
||||
if def.dex then byDex[def.dex] = def end
|
||||
end
|
||||
local items = {}
|
||||
local seen, owned = 0, 0
|
||||
-- dex bound and number width come from constants; the fallbacks keep a
|
||||
-- cache imported before those keys existed on the Kanto numbering
|
||||
local constants = game.data.constants or {}
|
||||
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
|
||||
for n = 1, constants.dexSize or 151 do
|
||||
local dexSize = constants.dexSize or 151
|
||||
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
|
||||
-- highest number the player has seen, not at the end of the roster
|
||||
local maxSeen = 0
|
||||
local seen, owned = 0, 0
|
||||
for n = 1, dexSize do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local label
|
||||
if dex.owned[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
owned = owned + 1
|
||||
seen = seen + 1
|
||||
maxSeen = n
|
||||
elseif dex.seen[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
seen = seen + 1
|
||||
else
|
||||
label = (numFmt .. " -----"):format(n)
|
||||
maxSeen = n
|
||||
end
|
||||
table.insert(items, {
|
||||
label = label,
|
||||
end
|
||||
end
|
||||
local items = {}
|
||||
for n = 1, math.min(dexSize, maxSeen) do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local known = dex.owned[def.id] or dex.seen[def.id]
|
||||
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
|
||||
local name = known and def.name or "----------"
|
||||
items[#items + 1] = {
|
||||
num = numFmt:format(n),
|
||||
name = name,
|
||||
label = (numFmt .. " %s"):format(n, name),
|
||||
-- owned entries carry the pokéball marker like the original
|
||||
-- list; seen-only entries are just the name
|
||||
ball = dex.owned[def.id] or nil,
|
||||
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
|
||||
})
|
||||
value = known and def.id or nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
||||
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu prints both counts with
|
||||
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
|
||||
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
|
||||
-- here: a bare ListMenu footer goes through the 18-column text wrap,
|
||||
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
|
||||
-- half landed on the list's last row at y=120 (#639).
|
||||
footer = Strings("SEEN %3d OWN %3d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
|
||||
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
|
||||
-- stood in for by a PNG of the entry page saved under prints/.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
end,
|
||||
})
|
||||
list.sgbPalettes = PokedexMenu.sgbPalettes
|
||||
return list
|
||||
self.items = items
|
||||
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu (#639)
|
||||
self.seenCount, self.ownedCount = seen, owned
|
||||
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
|
||||
return self
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
|
||||
local function beep(self)
|
||||
if not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
function PokedexMenu:rows()
|
||||
return math.min(ROWS, #self.items)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:290-313
|
||||
function PokedexMenu:syncScroll()
|
||||
local rows = self:rows()
|
||||
if rows == 0 then
|
||||
self.index, self.scroll = 1, 0
|
||||
return
|
||||
end
|
||||
self.index = math.max(1, math.min(#self.items, self.index))
|
||||
if self.index - self.scroll > rows then self.scroll = self.index - rows end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
|
||||
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
|
||||
function PokedexMenu:pageScroll(dir)
|
||||
local n = #self.items
|
||||
if n < ROWS then return end
|
||||
local row = self.index - self.scroll
|
||||
local scroll = self.scroll + dir * ROWS
|
||||
scroll = math.max(0, math.min(n - ROWS, scroll))
|
||||
self.scroll = scroll
|
||||
self.index = math.min(n, scroll + row)
|
||||
end
|
||||
|
||||
function PokedexMenu:close()
|
||||
local top = self.game.stack:top()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function PokedexMenu:update(dt)
|
||||
-- back on top of the stack, so the side menu's hollow '▷' is gone
|
||||
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
|
||||
self.hollowIndex = nil
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
elseif input:wasPressed("left") then
|
||||
self:pageScroll(-1)
|
||||
elseif input:wasPressed("right") then
|
||||
self:pageScroll(1)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
self.onChoose(self.items[self.index], self)
|
||||
return
|
||||
end
|
||||
self:syncScroll()
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:371-375, :81-85
|
||||
function PokedexMenu:sideItems()
|
||||
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
labels[#labels + 1] = Strings("PRNT")
|
||||
end
|
||||
labels[#labels + 1] = Strings("QUIT")
|
||||
return labels
|
||||
end
|
||||
|
||||
local function sideRowY(menu, row)
|
||||
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
|
||||
end
|
||||
|
||||
local function drawSideMenu(self)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for row = 1, #self.items do
|
||||
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
|
||||
end
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
|
||||
-- .exitPokedex (#571)
|
||||
local function chooseEntry(item, dexList)
|
||||
local game = dexList.game
|
||||
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
|
||||
if not item.value then return end
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
|
||||
-- PrintPokedexEntry
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- engine/menus/pokedex.asm .exitPokedex
|
||||
dexList:close()
|
||||
if dexList.onCancel then dexList.onCancel() end
|
||||
end }
|
||||
local ty = dexList.rowsAt.items - 2
|
||||
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
|
||||
-- the block is already on screen, so the side menu is the cursor alone:
|
||||
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
|
||||
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
|
||||
side.draw = drawSideMenu
|
||||
dexList.hollowIndex = dexList.index
|
||||
game.stack:push(side)
|
||||
end
|
||||
PokedexMenu.onChoose = chooseEntry
|
||||
|
||||
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
|
||||
-- name; the port paints the same marker rather than carry a one-tile sheet
|
||||
local function drawBall(x, y)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 1.2)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function PokedexMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local at = self.rowsAt
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
|
||||
-- fences SEEN / OWN off from the side-menu block
|
||||
for ty = 0, 17 do
|
||||
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("─"):rep(5), 120, at.rule * 8)
|
||||
Font.draw(Strings("CONTENTS"), 8, 8)
|
||||
Font.draw(Strings("SEEN"), 128, at.seen * 8)
|
||||
Font.draw(Strings("OWN"), 128, at.own * 8)
|
||||
-- PrintNumber's fixed three-digit field ends on column 19
|
||||
local function count(n, ty)
|
||||
local text = tostring(n)
|
||||
Font.draw(text, 152 - Font.width(text), ty * 8)
|
||||
end
|
||||
count(self.seenCount, at.seen + 1)
|
||||
count(self.ownedCount, at.own + 1)
|
||||
local labels = self:sideItems()
|
||||
for i, label in ipairs(labels) do
|
||||
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
|
||||
end
|
||||
for row = 1, self:rows() do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
local y = FIRST_ROW_Y + (row - 1) * 16
|
||||
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
|
||||
Font.draw(item.num, NUM_X, y - 8)
|
||||
if item.ball then drawBall(BALL_X, y) end
|
||||
Font.draw(item.name, NAME_X, y)
|
||||
if i == self.index then
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return PokedexMenu
|
||||
|
||||
+14
-23
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
|
||||
local self = setmetatable({}, SlotMachine)
|
||||
self.game = game
|
||||
self.wheels = game.data.field.slotWheels
|
||||
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
|
||||
self.stage = "intro"
|
||||
-- bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
|
||||
self.stage = "bet"
|
||||
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
|
||||
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
|
||||
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
|
||||
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
|
||||
self.flash = false
|
||||
end
|
||||
|
||||
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
|
||||
-- the "One more go?" YES/NO prompt.
|
||||
function SlotMachine:updateYesNo(onYes)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
|
||||
local input = self.game.input
|
||||
local save = self.game.save
|
||||
|
||||
if self.stage == "intro" then
|
||||
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
|
||||
self:updateYesNo(function() self:enterBet() end)
|
||||
return
|
||||
end
|
||||
|
||||
if self.stage == "message" then
|
||||
if self.exitTimer then
|
||||
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
|
||||
@@ -638,9 +632,7 @@ end
|
||||
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
|
||||
function SlotMachine:drawBottom()
|
||||
local lines
|
||||
if self.stage == "intro" then
|
||||
lines = { "A slot machine!", "Want to play?" }
|
||||
elseif self.stage == "bet" then
|
||||
if self.stage == "bet" then
|
||||
lines = { "Bet how many", "coins?" }
|
||||
elseif self.stage == "onemore" then
|
||||
lines = { "One more", "go?" }
|
||||
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
|
||||
Font.draw(lines[1] or "", 8, 14 * 8)
|
||||
Font.draw(lines[2] or "", 8, 16 * 8)
|
||||
if self.stage == "bet" then
|
||||
Font.drawBox(14, 11, 6, 5)
|
||||
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
|
||||
Font.drawBox(14, 11, 6, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("×3", 16 * 8, 12 * 8)
|
||||
Font.draw("×2", 16 * 8, 13 * 8)
|
||||
Font.draw("×1", 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
|
||||
elseif self.stage == "intro" or self.stage == "onemore" then
|
||||
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
|
||||
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
|
||||
-- floats above the reels instead.
|
||||
local by = self.stage == "intro" and 6 or 11
|
||||
Font.drawBox(13, by, 6, 5)
|
||||
elseif self.stage == "onemore" then
|
||||
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
|
||||
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
|
||||
Font.drawBox(14, 12, 6, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
|
||||
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
|
||||
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -200,12 +200,12 @@ function StartMenu.new(game)
|
||||
anchor = "topright" })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
menu.index = math.min(game.startMenuIndex or 1, #items)
|
||||
menu:clampScroll()
|
||||
local baseUpdate = menu.update
|
||||
menu.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
game.save.startMenuIndex = self.index
|
||||
game.startMenuIndex = self.index
|
||||
end
|
||||
|
||||
-- inside the Safari Zone the start menu also shows remaining steps and
|
||||
|
||||
+43
-27
@@ -130,6 +130,25 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the marker wears its own sheet's OBJ palette and shade-0 keying
|
||||
-- engine/items/town_map.asm:342
|
||||
local function markerSheet(def, seed)
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(def, seed)
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
|
||||
if not (ok and img) then return nil, nil end
|
||||
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
-- engine/items/town_map.asm:347
|
||||
do
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
|
||||
-- palette and shade-0 keying -- engine/items/town_map.asm:342
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(red, "player")
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage,
|
||||
red and red.image, colors, group)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
img:getDimensions())
|
||||
end
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
self.playerSheet, self.playerQuad =
|
||||
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
|
||||
"player")
|
||||
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
|
||||
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
|
||||
if self.fly then
|
||||
self.birdSheet, self.birdQuad =
|
||||
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
|
||||
or sprites.SPRITE_BIRD, "bird")
|
||||
end
|
||||
self.sel = 1
|
||||
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
|
||||
@@ -402,8 +407,19 @@ function TownMap:draw()
|
||||
end
|
||||
end
|
||||
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
|
||||
-- LoadTownMap_Fly's .inputLoop has no blinking animation
|
||||
-- engine/items/town_map.asm:190-198
|
||||
local showCursor = true
|
||||
if GameVersion.generation() == 1 then
|
||||
if self.fly and self.birdSheet then
|
||||
if selected then
|
||||
local x, y = markerXY(selected)
|
||||
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
|
||||
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
end
|
||||
end
|
||||
showCursor = false
|
||||
elseif GameVersion.generation() == 1 then
|
||||
showCursor = self.blink < 25
|
||||
else
|
||||
showCursor = self.blink % 16 < 10
|
||||
|
||||
@@ -605,23 +605,66 @@ function BattleState:animData(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
if not def then return nil end
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
|
||||
if entry and entry.anim then return entry.anim end
|
||||
end
|
||||
return def.anim
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
|
||||
function BattleState:frontPicReplaced(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
local vanilla = def and def.spriteFront
|
||||
if mon and mon.species == Unown.SPECIES then
|
||||
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
|
||||
or vanilla
|
||||
end
|
||||
if type(vanilla) ~= "string" then return false end
|
||||
local _, _, path = self:pic(mon, false)
|
||||
if type(path) == "string" and path ~= vanilla then return true end
|
||||
return Assets.resolve(vanilla) ~= vanilla
|
||||
end
|
||||
|
||||
-- one cart asset, two files on the pokemon.sprite seam (#1827)
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
|
||||
function BattleState:animSheetPath(mon, data)
|
||||
local path = data and data.sheet
|
||||
if type(path) ~= "string" then return nil, false end
|
||||
if not Runtime.wantsHook("pokemon.sprite") then
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
local letter
|
||||
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
|
||||
local hooked = Runtime.call("pokemon.sprite",
|
||||
function(value) return value end, path, {
|
||||
species = mon and mon.species,
|
||||
side = "front",
|
||||
kind = "battle_anim",
|
||||
mon = mon,
|
||||
data = (self.game and self.game.data) or nil,
|
||||
letter = letter,
|
||||
shiny = mon and mon.shiny and true or false,
|
||||
})
|
||||
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
|
||||
return hooked, true
|
||||
end
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
|
||||
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
|
||||
-- ../pokecrystal/engine/battle/core.asm:9112-9113
|
||||
function BattleState:startFrontAnim(mon)
|
||||
self.frontAnim = nil
|
||||
local data = self:animData(mon)
|
||||
if not data then return end
|
||||
local cached = self.picCache[data.sheet]
|
||||
local sheet, sheetReplaced = self:animSheetPath(mon, data)
|
||||
if not sheet then return end
|
||||
if not sheetReplaced and self:frontPicReplaced(mon) then return end
|
||||
local cached = self.picCache[sheet]
|
||||
if cached == nil then
|
||||
local ok, image = pcall(Assets.image, data.sheet)
|
||||
local ok, image = pcall(Assets.image, sheet)
|
||||
cached = ok and image or false
|
||||
self.picCache[data.sheet] = cached
|
||||
self.picCache[sheet] = cached
|
||||
end
|
||||
if not cached then return end
|
||||
local runner = MonAnim.new(data, "battle")
|
||||
|
||||
@@ -37,6 +37,13 @@ local TileSheet = require("src.ui.gen2.TileSheet")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles
|
||||
-- drawn by drawOption/drawSearch below, declared here (rather than inline)
|
||||
-- so Strings.source puts them in the catalog harvest.
|
||||
local OPTION_LABEL = Strings.source(" OPTION ")
|
||||
local SEARCH_LABEL = Strings.source(" SEARCH ")
|
||||
|
||||
local PokedexMenu = {}
|
||||
PokedexMenu.__index = PokedexMenu
|
||||
@@ -776,7 +783,6 @@ function PokedexMenu:printEntry()
|
||||
local row = self:current()
|
||||
if not row then return end
|
||||
local Printer = require("src.core.Printer")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local name = (self.pokemon and self.pokemon[row.species]
|
||||
and self.pokemon[row.species].name) or tostring(row.species)
|
||||
@@ -1385,7 +1391,7 @@ function PokedexMenu:drawOption()
|
||||
-- `db $3b, " OPTION ", $3c`: the two end-cap tiles are the dex sheet's, not
|
||||
-- font glyphs.
|
||||
self:tile(0x3b, 0, 1)
|
||||
self:text(" OPTION ", 1, 1)
|
||||
self:text(Strings(OPTION_LABEL), 1, 1)
|
||||
self:tile(0x3c, 9, 1)
|
||||
local rows = self:optionRows()
|
||||
for i, row in ipairs(rows) do
|
||||
@@ -1405,7 +1411,7 @@ function PokedexMenu:drawSearch()
|
||||
self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H)
|
||||
self:border(0, 2, 14, 18)
|
||||
self:tile(0x3b, 0, 1)
|
||||
self:text(" SEARCH ", 1, 1)
|
||||
self:text(Strings(SEARCH_LABEL), 1, 1)
|
||||
self:tile(0x3c, 9, 1)
|
||||
self:text("TYPE1", 3, 4)
|
||||
self:text("TYPE2", 3, 6)
|
||||
|
||||
@@ -336,7 +336,7 @@ function SummaryMenu:startPicAnim()
|
||||
if not def then return end
|
||||
local data = def.anim
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
|
||||
if entry and entry.anim then data = entry.anim end
|
||||
end
|
||||
if not data then return end
|
||||
@@ -1105,9 +1105,14 @@ function SummaryMenu:drawEggIconFallback(colors)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function SummaryMenu:drawPlacements(list)
|
||||
-- engine/gfx/color.asm:342-359
|
||||
function SummaryMenu:drawPlacements(list, palette)
|
||||
for _, entry in ipairs(list) do
|
||||
Chrome.print(entry.text, entry.x, entry.y)
|
||||
if palette then
|
||||
Chrome.printThrough(entry.text, entry.x, entry.y, palette)
|
||||
else
|
||||
Chrome.print(entry.text, entry.x, entry.y)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1168,7 +1173,7 @@ function SummaryMenu:drawPinkPage()
|
||||
HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font)
|
||||
end
|
||||
self:drawVerticalDivider(9)
|
||||
self:drawPlacements(self:pinkPlacements())
|
||||
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
|
||||
|
||||
-- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and
|
||||
-- fills eight of them walking left, with the $40/$41 caps outside at (10,16)
|
||||
@@ -1188,12 +1193,12 @@ function SummaryMenu:drawPinkPage()
|
||||
end
|
||||
|
||||
function SummaryMenu:drawGreenPage()
|
||||
self:drawPlacements(self:greenPlacements())
|
||||
self:drawPlacements(self:greenPlacements(), self:lowerColors())
|
||||
end
|
||||
|
||||
function SummaryMenu:drawBluePage()
|
||||
self:drawVerticalDivider(10)
|
||||
self:drawPlacements(self:bluePlacements())
|
||||
self:drawPlacements(self:bluePlacements(), self:lowerColors())
|
||||
end
|
||||
|
||||
function SummaryMenu:drawMoveDetail()
|
||||
|
||||
+2
-1
@@ -934,9 +934,10 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
|
||||
|
||||
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
|
||||
if clickedOrActivated and not mobile() then
|
||||
if isHandheld and clickedOrActivated and not mobile() then
|
||||
VirtualKeyboard.open({
|
||||
text = value,
|
||||
targetId = id,
|
||||
|
||||
@@ -48,7 +48,23 @@ local ROWS_SYM = {
|
||||
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
|
||||
}
|
||||
|
||||
local function isLinuxHandheld()
|
||||
return os.getenv("HANDHELD") == "1"
|
||||
or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1"
|
||||
or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1"
|
||||
or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1"
|
||||
or os.getenv("ANBERNIC") == "1"
|
||||
end
|
||||
|
||||
function VirtualKeyboard.isSupported()
|
||||
return isLinuxHandheld()
|
||||
end
|
||||
|
||||
function VirtualKeyboard.open(opts)
|
||||
if not isLinuxHandheld() then return false end
|
||||
opts = opts or {}
|
||||
VirtualKeyboard.active = true
|
||||
VirtualKeyboard.text = tostring(opts.text or "")
|
||||
@@ -58,6 +74,7 @@ function VirtualKeyboard.open(opts)
|
||||
VirtualKeyboard.row = 1
|
||||
VirtualKeyboard.col = 1
|
||||
VirtualKeyboard.mode = 1
|
||||
return true
|
||||
end
|
||||
|
||||
function VirtualKeyboard.close(confirmed)
|
||||
|
||||
@@ -48,6 +48,10 @@ local WILD_ENCOUNTER_GRACE_STEPS = 3
|
||||
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
|
||||
-- the player's sprite, so path1 starts exactly on the player.
|
||||
local FLY_ANCHOR = { 0x3C, 0x48 }
|
||||
-- StopMusic's fade-out control byte and the 7 volume steps it waits out
|
||||
-- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36
|
||||
local FLY_FADE_CONTROL = 4
|
||||
local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7
|
||||
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
|
||||
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
|
||||
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
|
||||
@@ -1189,6 +1193,17 @@ function OverworldState:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the
|
||||
-- world holds and the bird is not on screen yet -- home/overworld.asm:772
|
||||
if self.flyFade then
|
||||
self.flyFade = self.flyFade - 1
|
||||
if self.flyFade <= 0 then
|
||||
self.flyFade = nil
|
||||
self.flyAnim = { phase = "flap", t = 0 }
|
||||
end
|
||||
self.player:update()
|
||||
return
|
||||
end
|
||||
if self.flyAnim then
|
||||
-- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
|
||||
-- in-place flap is 8 pairs, then the two paths with a 40-frame beat
|
||||
@@ -2070,7 +2085,9 @@ function OverworldState:flyTo(mapId)
|
||||
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
|
||||
-- the exit over the top-left -- the warp fades only once the bird is
|
||||
-- gone (#702). fxBird draws it; the player hides for the whole flight.
|
||||
self.flyAnim = { phase = "flap", t = 0 }
|
||||
-- engine/overworld/player_animations.asm:123, home/overworld.asm:772
|
||||
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
|
||||
self.flyFade = FLY_FADE_FRAMES
|
||||
self.player.inputLocked = true
|
||||
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
||||
end
|
||||
@@ -2093,6 +2110,9 @@ function OverworldState:beginTeleportOut(onDone)
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
-- 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")
|
||||
self.player.surfing = false
|
||||
self:syncSurfingPikachu()
|
||||
@@ -2315,6 +2335,15 @@ function OverworldState.benchGuyText(data, save, label)
|
||||
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
|
||||
end
|
||||
|
||||
-- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case
|
||||
-- falls into .bcd20 -- engine/events/hidden_items.asm:79
|
||||
function OverworldState.hiddenCoinPayout(amount)
|
||||
amount = tonumber(amount) or 0
|
||||
if amount == 10 then return 10 end
|
||||
if amount == 20 or amount == 40 then return 20 end
|
||||
return 100
|
||||
end
|
||||
|
||||
-- Hidden events at the faced cell (data/events/hidden_events.asm):
|
||||
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
|
||||
-- StartSlotMachine seats open the minigame. Taken spots persist in
|
||||
@@ -2359,9 +2388,10 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not save.inventory.COIN_CASE then return false end
|
||||
save.hiddenTaken[key] = true
|
||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
||||
local paid = OverworldState.hiddenCoinPayout(h.coins)
|
||||
save.coins = math.min(9999, (save.coins or 0) + paid)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins),
|
||||
Strings("%s found\n%d coins!", save.player.name, paid),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
@@ -2391,7 +2421,19 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
else
|
||||
-- one machine per visit is secretly lucky
|
||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
|
||||
Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot)
|
||||
local lucky = seatIndex == self.luckySlot
|
||||
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
|
||||
Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText
|
||||
or romText(Game.data, "_PlaySlotMachineText",
|
||||
"A slot machine!\nWant to play?"), nil, {
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
self.emote = {
|
||||
npc = self.player, frames = 60, bubble = 3,
|
||||
onDone = function() Screens.push(Game, "SlotMachine", lucky) end,
|
||||
}
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -3829,6 +3871,10 @@ function OverworldState:checkTrainerSight()
|
||||
if self.player.moving or self.engaging then return end
|
||||
if Game.stack:top() ~= self then return end
|
||||
local p = self.player
|
||||
-- a map contribution opts a trainer out of CheckFightingMapTrainers with
|
||||
-- `noSight = { TEXT_... = true }` -- home/trainers.asm:129
|
||||
local view = mapScripts.get and mapScripts.get(self.map.id)
|
||||
local noSight = view and view.noSight
|
||||
local cancelled = self.cancelledTrainerSight
|
||||
if cancelled and (cancelled.playerX ~= p.cellX
|
||||
or cancelled.playerY ~= p.cellY) then
|
||||
@@ -3842,7 +3888,7 @@ function OverworldState:checkTrainerSight()
|
||||
if d.trainerClass and not npc.moving
|
||||
and not (cancelled and cancelled.npcId == npc.id)
|
||||
and not self:trainerDefeated(npc)
|
||||
and not mapScripts.talkScript(self.map.id, d.text)
|
||||
and not (noSight and d.text and noSight[d.text])
|
||||
and trainerSpriteOnScreen(npc, p) then
|
||||
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
||||
local range = header and header.range or 0
|
||||
|
||||
@@ -182,6 +182,9 @@ function Player:update()
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
if self.spinning then
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
end
|
||||
if self.spinFrames then
|
||||
self.spinFrames = self.spinFrames - 1
|
||||
if self.spinFrames <= 0 then
|
||||
@@ -261,11 +264,6 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
--
|
||||
-- The last return says the player is mid-ledge-hop, which is what the 2D
|
||||
-- path draws the ground shadow from and a 3D path turns into vertical lift.
|
||||
--
|
||||
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
|
||||
-- and draw() may run per frame -- and draw() is written in terms of pose()
|
||||
-- to keep that true by construction. (hopFrames counts down in
|
||||
-- Player:update, on the fixed step, so it is safe to read here.)
|
||||
function Player:pose()
|
||||
local py = self.py
|
||||
local hopping = false
|
||||
@@ -288,10 +286,8 @@ function Player:pose()
|
||||
-- the leg cadence
|
||||
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
|
||||
if self.spinning then
|
||||
-- spinner tiles whirl the sprite on its standing pose, one facing
|
||||
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
|
||||
-- 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
|
||||
-- teleport arrivals spin the sprite down into place
|
||||
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
||||
|
||||
@@ -187,6 +187,11 @@ function FieldMoves.bindEngineFlags(order)
|
||||
-- row, so every id from BUG_CONTEST_TIMER up shifts one.
|
||||
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
|
||||
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
|
||||
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS or {}) do
|
||||
row.goldFlag = row.goldFlag or row.flag
|
||||
row.flag = byName[row.name] or row.goldFlag
|
||||
end
|
||||
return flags
|
||||
end
|
||||
|
||||
@@ -475,33 +480,35 @@ end
|
||||
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
|
||||
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
|
||||
-- and what FieldMoves.hasVisitedSpawn below actually reads.
|
||||
-- Ids are pokegold's; bindEngineFlags rebinds by name (pokecrystal
|
||||
-- constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts them +1).
|
||||
FieldMoves.FLYPOINTS = {
|
||||
-- Johto
|
||||
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 },
|
||||
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 },
|
||||
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 },
|
||||
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 },
|
||||
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 },
|
||||
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 },
|
||||
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 },
|
||||
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 },
|
||||
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 },
|
||||
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 },
|
||||
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 },
|
||||
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 },
|
||||
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64, name = "ENGINE_FLYPOINT_NEW_BARK" },
|
||||
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65, name = "ENGINE_FLYPOINT_CHERRYGROVE" },
|
||||
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66, name = "ENGINE_FLYPOINT_VIOLET" },
|
||||
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67, name = "ENGINE_FLYPOINT_AZALEA" },
|
||||
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69, name = "ENGINE_FLYPOINT_GOLDENROD" },
|
||||
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71, name = "ENGINE_FLYPOINT_ECRUTEAK" },
|
||||
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70, name = "ENGINE_FLYPOINT_OLIVINE" },
|
||||
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68, name = "ENGINE_FLYPOINT_CIANWOOD" },
|
||||
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72, name = "ENGINE_FLYPOINT_MAHOGANY" },
|
||||
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73, name = "ENGINE_FLYPOINT_LAKE_OF_RAGE" },
|
||||
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74, name = "ENGINE_FLYPOINT_BLACKTHORN" },
|
||||
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75, name = "ENGINE_FLYPOINT_SILVER_CAVE" },
|
||||
-- Kanto
|
||||
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 },
|
||||
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 },
|
||||
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 },
|
||||
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 },
|
||||
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 },
|
||||
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 },
|
||||
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 },
|
||||
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 },
|
||||
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 },
|
||||
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 },
|
||||
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 },
|
||||
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 },
|
||||
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52, name = "ENGINE_FLYPOINT_PALLET" },
|
||||
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53, name = "ENGINE_FLYPOINT_VIRIDIAN" },
|
||||
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54, name = "ENGINE_FLYPOINT_PEWTER" },
|
||||
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55, name = "ENGINE_FLYPOINT_CERULEAN" },
|
||||
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57, name = "ENGINE_FLYPOINT_VERMILION" },
|
||||
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56, name = "ENGINE_FLYPOINT_ROCK_TUNNEL" },
|
||||
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58, name = "ENGINE_FLYPOINT_LAVENDER" },
|
||||
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60, name = "ENGINE_FLYPOINT_CELADON" },
|
||||
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59, name = "ENGINE_FLYPOINT_SAFFRON" },
|
||||
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61, name = "ENGINE_FLYPOINT_FUCHSIA" },
|
||||
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62, name = "ENGINE_FLYPOINT_CINNABAR" },
|
||||
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63, name = "ENGINE_FLYPOINT_INDIGO_PLATEAU" },
|
||||
}
|
||||
|
||||
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
|
||||
|
||||
+27
-11
@@ -929,8 +929,8 @@ function World:load()
|
||||
-- `hold` is the same story one argument along: the cart `pause` a held box
|
||||
-- stands through (FindItemInBallScript's `pause 60`). Dropping it made the
|
||||
-- box hand back the instant it finished typing.
|
||||
showText = function(body, onDone, stay, hold)
|
||||
self:showText(body, onDone, stay, hold)
|
||||
showText = function(body, onDone, stay, hold, sfxWait)
|
||||
self:showText(body, onDone, stay, hold, sfxWait)
|
||||
end,
|
||||
facePlayer = function()
|
||||
if self.talkNpc and self.player then
|
||||
@@ -4224,7 +4224,7 @@ function World:tryWildEncounter()
|
||||
-- That is what keeps the Ruins chambers empty until a wall has been solved.
|
||||
local monOpts = nil
|
||||
if roll.species == Unown.SPECIES then
|
||||
local flags = self:engineFlags()
|
||||
local flags = self:unownUnlockFlags()
|
||||
if not Unown.anyUnlocked(flags) then return false end
|
||||
-- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears
|
||||
-- the form, so a chamber only ever produces letters its own puzzle
|
||||
@@ -4846,6 +4846,19 @@ function World:engineFlagId(name, goldId)
|
||||
return ids[name] or goldId
|
||||
end
|
||||
|
||||
-- Unown.UNLOCK_SETS keys the flags by pokegold's ids; Crystal's are one
|
||||
-- higher (../pokecrystal/constants/engine_flags.asm:57-60 vs ../pokegold:56-59).
|
||||
function World:unownUnlockFlags()
|
||||
local engine = self:engineFlags()
|
||||
local out = {}
|
||||
for _, set in ipairs(Unown.UNLOCK_SETS) do
|
||||
if engine[self:engineFlagId(set.name, set.flag)] then
|
||||
out[set.flag] = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
|
||||
-- callbacks that set them (Route16AlwaysOnBikeCallback,
|
||||
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
|
||||
@@ -5338,7 +5351,7 @@ function World:sweetScentEncounter()
|
||||
-- stays empty for SWEET SCENT too.
|
||||
local monOpts = nil
|
||||
if roll.species == Unown.SPECIES then
|
||||
local flags = self:engineFlags()
|
||||
local flags = self:unownUnlockFlags()
|
||||
if not Unown.anyUnlocked(flags) then return false end
|
||||
monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) }
|
||||
end
|
||||
@@ -5618,15 +5631,16 @@ end
|
||||
function World:runWhirlpool(result)
|
||||
self:setNickname(result.mon)
|
||||
self:showText(Strings(result.text), function()
|
||||
self:replaceBlock(result.blockIndex, result.replacement)
|
||||
self:playWhirlpoolSound()
|
||||
self:playWhirlpoolSound(result.blockIndex, result.replacement)
|
||||
end)
|
||||
end
|
||||
|
||||
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
|
||||
-- -- engine/events/field_moves.asm:5-10 (#1717)
|
||||
function World:playWhirlpoolSound()
|
||||
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
|
||||
-- -- engine/events/field_moves.asm:5-10 (#1717). The block swap lands after
|
||||
-- it -- engine/events/overworld.asm:1157-1164
|
||||
function World:playWhirlpoolSound(blockIndex, replacement)
|
||||
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180,
|
||||
blockIndex = blockIndex, replacement = replacement }
|
||||
end
|
||||
|
||||
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
|
||||
@@ -6193,6 +6207,8 @@ function World:updateFieldMove()
|
||||
end
|
||||
if Sound.sfxBusy() and st.left > 0 then return end
|
||||
self.fieldMove = nil
|
||||
-- engine/events/overworld.asm:1163-1164
|
||||
if st.blockIndex then self:replaceBlock(st.blockIndex, st.replacement) end
|
||||
return
|
||||
end
|
||||
if st.phase == "strength" then
|
||||
@@ -7277,7 +7293,7 @@ end
|
||||
-- stack the overworld and the VM under it do not tick at all -- so the wait has
|
||||
-- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames
|
||||
-- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand.
|
||||
function World:showText(body, onDone, stay, hold)
|
||||
function World:showText(body, onDone, stay, hold, sfxWait)
|
||||
local game = self.game
|
||||
-- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever
|
||||
-- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it
|
||||
@@ -7327,7 +7343,7 @@ function World:showText(body, onDone, stay, hold)
|
||||
game.stack:push(TextBox.new(game, body, function()
|
||||
self.textbox = nil
|
||||
if onDone then onDone() end
|
||||
end))
|
||||
end, sfxWait and { sfxWait = true } or nil))
|
||||
end
|
||||
|
||||
function World:pooledNpc(mapId, obj)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Manual check of the FLY picker's bird marker and the departure fade (#1840).
|
||||
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks the
|
||||
-- destination with it, with no blink (engine/items/town_map.asm:146-149,
|
||||
-- 177-179, 190-198); _LeaveMapAnim stops the music with fade control $4 before
|
||||
-- the bird appears (engine/overworld/player_animations.asm:123, home/overworld.asm:772).
|
||||
-- POKEPORT_DRIVER=tests/drivers/fly_bird_bug1840_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Music = require("src.core.Music")
|
||||
local TownMap = require("src.ui.TownMap")
|
||||
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 vol = game.save.options and game.save.options.musicVol
|
||||
if vol == 0 then
|
||||
U.log("music volume is 0 in options; raise it or the fade is inaudible")
|
||||
end
|
||||
if (game.save.options and game.save.options.sfxVol) == 0 then
|
||||
U.log("sfx volume is 0 in options; the bird's SFX_FLY will be silent too")
|
||||
end
|
||||
|
||||
-- BuildFlyLocationsList only offers visited towns
|
||||
game.save.visited = game.save.visited or {}
|
||||
for _, town in ipairs({ "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY",
|
||||
"CERULEAN_CITY", "CELADON_CITY" }) do
|
||||
game.save.visited[town] = true
|
||||
end
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
|
||||
U.wait(30)
|
||||
|
||||
local flown = nil
|
||||
Screens.push(game, "TownMap", { fly = true,
|
||||
onFly = function(mapId) flown = mapId end })
|
||||
U.wait(20)
|
||||
local map = game.stack:top()
|
||||
check("the FLY picker opened", getmetatable(map) == TownMap)
|
||||
check("it is in fly mode", map.fly == true)
|
||||
check("the destination marker is the bird sheet", map.birdSheet ~= nil)
|
||||
check("the blinking cursor art is still loaded for the plain viewer",
|
||||
map.bg == nil or map.bg.cursor ~= nil)
|
||||
U.log("fly destinations:", #(map.locs or {}))
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker.png")
|
||||
|
||||
-- the marker must not blink: sample the same screen a few frames apart
|
||||
map.blink = 0
|
||||
U.wait(2)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_a.png")
|
||||
map.blink = 30
|
||||
U.wait(2)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_b.png")
|
||||
U.log("compare", SHOT_DIR .. "/bug1840_flypicker_a.png",
|
||||
"with _b.png: they must be identical")
|
||||
|
||||
U.tap(game, "up")
|
||||
U.wait(20)
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("A picks a destination", flown ~= nil)
|
||||
U.log("flying to", tostring(flown))
|
||||
|
||||
local faded = false
|
||||
local realFade = Music.fadeOut
|
||||
Music.fadeOut = function(control, pending)
|
||||
faded = control
|
||||
return realFade(control, pending)
|
||||
end
|
||||
local ow = game.overworld
|
||||
if flown then ow:flyTo(flown) end
|
||||
U.wait(1)
|
||||
check("departure asks the music to fade", faded ~= false)
|
||||
check("with StopMusic's control byte $4", faded == 4)
|
||||
check("the world holds during the fade", ow.flyFade ~= nil)
|
||||
check("and the bird is not on screen yet", ow.flyAnim == nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_fading.png")
|
||||
|
||||
local flapping = false
|
||||
for _ = 1, 120 do
|
||||
if ow.flyAnim then flapping = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the bird appears once the fade is done", flapping)
|
||||
Music.fadeOut = realFade
|
||||
U.wait(6)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_bird.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1840_{flypicker,fading,bird}.png")
|
||||
|
||||
U.log("Right: the picker marks the town with a small bird that holds still,")
|
||||
U.log("and pressing A drops the map theme to silence before the bird flaps.")
|
||||
U.log("The near-miss is a square bracket cursor blinking on the town, or the")
|
||||
U.log("theme still playing under the flap and cutting off at the warp.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Eye/ear check on the Pokemon Tower 6F ghost scene (#1849): entry wipe,
|
||||
-- the "GHOST appeared!" wording, the SILPH SCOPE unveil colours, and the
|
||||
-- cry landing on the CUBONE's-mother line.
|
||||
-- pokered engine/battle/core.asm:6695-6702, data/text/text_2.asm:1251-1255,
|
||||
-- engine/battle/ghost_marowak_anim.asm:3-5,77, scripts/PokemonTower6F.asm:137-148.
|
||||
-- POKEPORT_DRIVER=tests/drivers/ghost_marowak_bug1849_test.lua POKEPORT_IDENTITY=bug1849 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- PokemonTower6FMarowakCoords (scripts/PokemonTower6F.asm:43-45) is
|
||||
-- (10, 16); the 7F stairs at (9, 16) sit right beside it, so the cell
|
||||
-- north of the trigger is open floor.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the MAROWAK cry is the audible half of this")
|
||||
U.log(" check and will not be heard. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 40) }
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
check("the SILPH SCOPE is in the bag (this is the unveil path)",
|
||||
(game.save.inventory.SILPH_SCOPE or 0) > 0)
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the floor: take any free neighbour of the trigger
|
||||
for _, d in ipairs({ { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }) do
|
||||
local cx, cy = TRIGGER.x + d[1], TRIGGER.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
local facing = (d[2] == -1 and "down") or (d[2] == 1 and "up")
|
||||
or (d[1] == -1 and "right") or "left"
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", facing)
|
||||
U.teleport(game, MAP, cx, cy, facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger cell ourselves; the Be gone... box opens on arrival
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local sawWipe = false
|
||||
local battle
|
||||
for _ = 1, 240 do
|
||||
U.hold(game, "down", 2)
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleTransition then
|
||||
sawWipe = true
|
||||
U.shot(game, DIR .. "/bug1849_entry_wipe.png")
|
||||
end
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
end
|
||||
check("the ghost battle started", battle ~= nil)
|
||||
check("an entry wipe ran before it (pushBattle, not a bare stack push)",
|
||||
sawWipe)
|
||||
|
||||
if battle then
|
||||
check("the foe is disguised as the GHOST", battle.enemy.name == "GHOST")
|
||||
U.log("intro line reads:", (tostring(battle.introText):gsub("\n", " / ")))
|
||||
check("the intro line is \"GHOST appeared!\", with no article",
|
||||
battle.introText == "GHOST\nappeared!")
|
||||
check("the scope unveil is queued", battle.scopeReveal == true)
|
||||
|
||||
-- ride the unveil: mash A through the boxes and shoot the anim
|
||||
local shotFlash, shotFade, shotDone = false, false, false
|
||||
for _ = 1, 900 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local gr = battle.ghostReveal
|
||||
if gr then
|
||||
if not shotFlash and gr.t >= 20 then
|
||||
shotFlash = true
|
||||
U.shot(game, DIR .. "/bug1849_unveil_flash.png")
|
||||
end
|
||||
if not shotFade and gr.t >= 100 then
|
||||
shotFade = true
|
||||
U.shot(game, DIR .. "/bug1849_unveil_fadein.png")
|
||||
end
|
||||
elseif shotFade and not shotDone then
|
||||
shotDone = true
|
||||
U.shot(game, DIR .. "/bug1849_unveiled.png")
|
||||
break
|
||||
end
|
||||
end
|
||||
check("the unveil animation played", shotFlash and shotFade)
|
||||
check("the real name came back: " .. tostring(battle.enemy.name),
|
||||
battle.enemy.name ~= "GHOST")
|
||||
end
|
||||
|
||||
U.log("Shots are in " .. DIR .. ": bug1849_entry_wipe, _unveil_flash,")
|
||||
U.log("_unveil_fadein, _unveiled.png. In OG RED mode MarowakAnim draws the")
|
||||
U.log("pic as a sprite under OBJ palette 1, so the flashing ghost and the")
|
||||
U.log("fading-in MAROWAK are green (pink in Blue) and only settle back to")
|
||||
U.log("the red background palette once the animation ends. The near-miss is")
|
||||
U.log("a pic that stays red the whole way through.")
|
||||
U.log("Beat the MAROWAK and listen: the cry belongs on the CUBONE's-mother")
|
||||
U.log("box, not on the one after it.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
-- #1860: jingles cut or dropped by the text blip. GiveItemScript
|
||||
-- (pokegold engine/overworld/scripting.asm:441-449), Oak's rating
|
||||
-- (engine/events/prof_oaks_pc.asm:14-21), bug contest judging
|
||||
-- (engine/events/bug_contest/judging.asm:29-32).
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_jingle_bug1860_test.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map and world.vm, "gold world did not boot")
|
||||
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 7) == 0 then
|
||||
U.log("WARNING: options.sfxVol is 0 -- every jingle below will be")
|
||||
U.log("WARNING: silent; raise SFX volume before judging this by ear")
|
||||
end
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
|
||||
end
|
||||
|
||||
local function specialId(name)
|
||||
for index, entry in ipairs(world.vm.specialOrder or {}) do
|
||||
if entry == name then return index - 1 end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- World:specialSound resolves the numeric index (itemIdByIndex), and only
|
||||
-- a TM_HM-pocket item rings Sfx_GetTm.
|
||||
local tm
|
||||
for _, def in pairs(game.data.items or {}) do
|
||||
if type(def) == "table" and def.pocket == "TM_HM" and def.index then
|
||||
tm = def.index
|
||||
break
|
||||
end
|
||||
end
|
||||
check("cache names a TM_HM-pocket item", tm ~= nil)
|
||||
|
||||
-- Runs one scripted moment while mashing A every 4th frame the whole way.
|
||||
-- everBusy: a gated sfx started at all (the drop half of the bug).
|
||||
-- maxRun: longest unbroken stretch it kept sounding (the cut half).
|
||||
-- popEarly: a box with the sfx hold popped while the jingle still rang.
|
||||
local function runMoment(label, script, minRun)
|
||||
U.wait(30)
|
||||
world.vm:start(script)
|
||||
U.wait(2)
|
||||
local everBusy, maxRun, run = false, 0, 0
|
||||
local sawHeld, popEarly = false, false
|
||||
local pressIn = 4
|
||||
for _ = 1, 1500 do
|
||||
local busy = Sound.sfxBusy()
|
||||
if busy then
|
||||
everBusy = true
|
||||
run = run + 1
|
||||
if run > maxRun then maxRun = run end
|
||||
else
|
||||
run = 0
|
||||
end
|
||||
local top = game.stack:top()
|
||||
if top and top.sfxWait and busy then sawHeld = true end
|
||||
pressIn = pressIn - 1
|
||||
if pressIn <= 0 then
|
||||
pressIn = 4
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
|
||||
game.input.state.a = true
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
if top and top.sfxWait and busy and game.stack:top() ~= top then
|
||||
popEarly = true
|
||||
end
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
if not world:busy() and not game.stack:top() then break end
|
||||
end
|
||||
check(label .. ": a jingle started (not dropped by the blip)", everBusy)
|
||||
check(("%s: it survived mashed A for %d frames (want >= %d)")
|
||||
:format(label, maxRun, minRun), maxRun >= minRun)
|
||||
check(label .. ": the held box refused A while it rang",
|
||||
sawHeld and not popEarly)
|
||||
end
|
||||
|
||||
if tm then
|
||||
runMoment("verbosegiveitem TM", {
|
||||
{ op = "opentext" },
|
||||
{ op = "verbosegiveitem", args = { tm, 1 } },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 60)
|
||||
end
|
||||
|
||||
local oak = specialId("ProfOaksPCBoot")
|
||||
check("specialOrder names ProfOaksPCBoot", oak ~= nil)
|
||||
if oak then
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, caught = {} }
|
||||
game.save.pokedex.caught = {}
|
||||
for i = 1, 150 do game.save.pokedex.caught["DEX_SEED_" .. i] = true end
|
||||
runMoment("Oak's dex rating fanfare", {
|
||||
{ op = "opentext" },
|
||||
{ op = "special", id = oak },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 40)
|
||||
end
|
||||
|
||||
local judging = specialId("BugContestJudging")
|
||||
check("specialOrder names BugContestJudging", judging ~= nil)
|
||||
if judging then
|
||||
runMoment("bug contest place fanfares", {
|
||||
{ op = "opentext" },
|
||||
{ op = "special", id = judging },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 30)
|
||||
end
|
||||
|
||||
for _, line in ipairs(results) do U.log(line) end
|
||||
|
||||
U.log("Right sounds like: the TM jingle, the dex-rating fanfare and each")
|
||||
U.log("place fanfare play out in full even while A is mashed; the box under")
|
||||
U.log("each one only closes once its jingle has finished ringing.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
-- #1858: stats-screen strings stamped white paper over the tinted lower half.
|
||||
-- Rows 8-17 stay on BG palette 0, whose colour 0 LoadStatsScreenPals sets to
|
||||
-- the page tint, so a glyph's blank pixels show that tint -- engine/gfx/
|
||||
-- color.asm:342-359, engine/gfx/cgb_layouts.asm:167-192. Never POKEPORT_SPEED
|
||||
-- here: the page redraw and its cry are being judged in order.
|
||||
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_stats_text_paper_bug1858_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1858 love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
|
||||
-- The rows the lower half prints on: EXP POINTS / LEVEL UP / TO on the pink
|
||||
-- page, ITEM and the move list on green, <ID>№. and OT/ on blue.
|
||||
local TEXT_ROWS = {
|
||||
[SummaryMenu.PINK_PAGE] = {
|
||||
{ "the EXP POINTS row", 80, 72, 159, 79 },
|
||||
{ "the STATUS/ row", 0, 96, 55, 103 },
|
||||
{ "the LEVEL UP row", 80, 96, 159, 103 },
|
||||
},
|
||||
[SummaryMenu.GREEN_PAGE] = {
|
||||
{ "the ITEM row", 0, 64, 159, 71 },
|
||||
{ "the first move name", 64, 80, 159, 87 },
|
||||
{ "the first PP row", 96, 88, 159, 95 },
|
||||
},
|
||||
[SummaryMenu.BLUE_PAGE] = {
|
||||
{ "the ID No. row", 0, 72, 79, 79 },
|
||||
{ "the OT/ row", 0, 96, 79, 103 },
|
||||
{ "the ATTACK row", 88, 64, 159, 71 },
|
||||
},
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1858"
|
||||
local fails, lines = 0, {}
|
||||
local function claim(ok, text)
|
||||
if not ok then fails = fails + 1 end
|
||||
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
|
||||
return ok
|
||||
end
|
||||
local function tap(button)
|
||||
U.tap(game, button)
|
||||
U.wait(4)
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.wait(45)
|
||||
if not (game.world and game.world.map) then
|
||||
U.log("FAIL the gold world never booted, nothing to open a summary over")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local save = game.save
|
||||
save.player.name = "GOLD"
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 22),
|
||||
Mon.new(game.data, "TOTODILE", 18) }
|
||||
local lead = save.party[1]
|
||||
lead.item = "BERRY"
|
||||
lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45))
|
||||
claim(GbcPalette.available(), "the GBC shade-remap shader compiled")
|
||||
game.options = game.options or {}
|
||||
game.options.color = "gbc"
|
||||
save.options = game.options
|
||||
GbcPalette.applyOptions(game.options)
|
||||
claim(GbcPalette.mode == "gbc", "COLOR is GBC for the run")
|
||||
|
||||
-- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes
|
||||
-- a GB pixel a pixel and the paper countable.
|
||||
local function frameOf(screen)
|
||||
local G = love.graphics
|
||||
local canvas = G.newCanvas(160, 144)
|
||||
G.setCanvas(canvas)
|
||||
G.clear(0, 0, 0, 1)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
screen:draw()
|
||||
G.setCanvas()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return canvas:newImageData()
|
||||
end
|
||||
|
||||
local function whitePct(img, rect)
|
||||
local white, total = 0, 0
|
||||
for y = rect[3], rect[5] do
|
||||
for x = rect[2], rect[4] do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
total = total + 1
|
||||
if r * 255 > 250 and g * 255 > 250 and b * 255 > 250 then
|
||||
white = white + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return white / total * 100
|
||||
end
|
||||
|
||||
tap("start")
|
||||
local menu = top()
|
||||
if not claim(menu and menu.screenId == "Gen2StartMenu",
|
||||
"START opened the menu") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
for _ = 1, 10 do
|
||||
if menu.list:current().value == "pokemon" then break end
|
||||
tap("down")
|
||||
end
|
||||
tap("a")
|
||||
local party = top()
|
||||
if not claim(party and party.screenId == "Gen2PartyMenu",
|
||||
"that opened the party list") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
tap("a")
|
||||
tap("a")
|
||||
local summary = top()
|
||||
if not claim(summary and summary.screenId == "Gen2SummaryMenu",
|
||||
"STATS opened the summary") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local NAMES = { "pink", "green", "blue" }
|
||||
local function inspect(shot)
|
||||
local img = frameOf(summary)
|
||||
local page = summary.page
|
||||
for _, rect in ipairs(TEXT_ROWS[page] or {}) do
|
||||
local pct = whitePct(img, rect)
|
||||
U.log((" %-22s %5.1f%% white"):format(rect[1], pct))
|
||||
claim(pct <= 1, ("%s (%s page) has no white paper behind it"):format(
|
||||
rect[1], NAMES[page] or "?"))
|
||||
end
|
||||
U.shot(game, ("%s/%s.png"):format(out, shot))
|
||||
end
|
||||
|
||||
inspect("01-pink-page")
|
||||
tap("right")
|
||||
claim(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page")
|
||||
inspect("02-green-page")
|
||||
tap("right")
|
||||
claim(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page")
|
||||
inspect("03-blue-page")
|
||||
tap("right")
|
||||
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
U.log(("%d checks, %d failed"):format(#lines, fails))
|
||||
if fails > 0 then
|
||||
U.log("a FAIL above means the run is not showing what it claims to")
|
||||
end
|
||||
U.log("what right looks like: everything under the rule is one flat wash of")
|
||||
U.log("the page colour, with the letters and numbers sitting straight on it.")
|
||||
U.log("a white box the width of each string is the bug; a white box behind")
|
||||
U.log("only the numbers, or only the upper half tinted, is a half fix.")
|
||||
U.log("left and right turn the page; the pad is yours.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
-- #1862: the whirlpool must stay on screen for the whole surf wash.
|
||||
-- DisappearWhirlpool blanks hBGMapMode before the redraw, plays the sound and
|
||||
-- only then buffers the screen -- engine/events/overworld.asm:1150-1165,
|
||||
-- engine/events/field_moves.asm:5-10. No POKEPORT_SPEED: audio runs on its
|
||||
-- own real-time accumulator and the ordering is the whole point.
|
||||
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_whirlpool_block_bug1862_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1862 love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
|
||||
-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30)
|
||||
-- and (28,48). Stand one cell north of the first and face it.
|
||||
local MAP = "ROUTE_41"
|
||||
local WHIRL = { x = 22, y = 12 }
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1862"
|
||||
local fails, lines = 0, {}
|
||||
local function claim(ok, text)
|
||||
if not ok then fails = fails + 1 end
|
||||
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
|
||||
return ok
|
||||
end
|
||||
local function report()
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
U.log(("%d checks, %d failed"):format(#lines, fails))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
U.log("FAIL the gold world never booted, nothing to drain")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
|
||||
if vol == 0 then
|
||||
U.log("SFX VOL is ZERO, so the wash this is timed against is inaudible;")
|
||||
U.log("turn it up in OPTION before judging anything by ear.")
|
||||
end
|
||||
|
||||
local badges = game.save.player.badges or {}
|
||||
game.save.player.badges = badges
|
||||
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
|
||||
local swimmer = Mon.new(game.data, "LAPRAS", 30,
|
||||
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
|
||||
claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL")
|
||||
game.save.party = { swimmer }
|
||||
|
||||
world:applyPlayerState(FieldMoves.PLAYER_SURF)
|
||||
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
|
||||
U.wait(15)
|
||||
world.noWildEncounters = true
|
||||
|
||||
local ctx = world:fieldContext()
|
||||
local facing = Permissions.isWhirlpool(ctx.facingColl)
|
||||
if not facing then
|
||||
-- A re-import moved it: take any whirlpool with open water above it.
|
||||
local def = world.maps[MAP]
|
||||
for y = 1, (def.height or 0) * 2 - 1 do
|
||||
for x = 0, (def.width or 0) * 2 - 1 do
|
||||
if not facing and Permissions.isWhirlpool(world.map:cellCollision(x, y))
|
||||
and Permissions.isWater(world.map:cellCollision(x, y - 1)) then
|
||||
WHIRL.x, WHIRL.y = x, y
|
||||
world:setMap(MAP, x, y - 1, "down")
|
||||
world:applyPlayerState(FieldMoves.PLAYER_SURF)
|
||||
U.wait(10)
|
||||
ctx = world:fieldContext()
|
||||
facing = Permissions.isWhirlpool(ctx.facingColl)
|
||||
U.log(("note: using the whirlpool at (%d,%d)"):format(x, y))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
claim(facing, ("facing the whirlpool at (%d,%d) on %s"):format(
|
||||
WHIRL.x, WHIRL.y, MAP))
|
||||
local blocks = world.maps[MAP] and world.maps[MAP].blocks
|
||||
local index = ctx.facingBlockIndex
|
||||
local before = blocks and index and blocks[index]
|
||||
claim(before ~= nil, "and the block behind it is readable")
|
||||
if not (facing and before) then
|
||||
report()
|
||||
U.log("nothing to drive; stopping rather than faking the moment")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, out .. "/01-facing.png")
|
||||
|
||||
local function tap(button, gap)
|
||||
table.insert(game.input.pressQueue, button)
|
||||
game.input.state[button] = true
|
||||
coroutine.yield()
|
||||
game.input.state[button] = false
|
||||
for _ = 1, (gap or 8) do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- A into the whirlpool, then A through the ask box (YES is the default) and
|
||||
-- the USED WHIRLPOOL line.
|
||||
for _ = 1, 24 do
|
||||
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
|
||||
tap("a")
|
||||
end
|
||||
claim(world.fieldMove and world.fieldMove.phase == "whirlpoolsfx",
|
||||
"the drain reached PlayWhirlpoolSound")
|
||||
claim(blocks[index] == before,
|
||||
"the whirlpool is still in the block buffer as the wash starts")
|
||||
U.shot(game, out .. "/02-wash-starts.png")
|
||||
|
||||
local swapped, phaseFrames = nil, 0
|
||||
for _ = 1, 300 do
|
||||
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then
|
||||
phaseFrames = phaseFrames + 1
|
||||
if blocks[index] ~= before and not swapped then swapped = phaseFrames end
|
||||
if phaseFrames == 6 then U.shot(game, out .. "/03-mid-wash.png") end
|
||||
end
|
||||
if not world:busy() then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
claim(swapped == nil,
|
||||
"and it stayed there for every frame of the wash")
|
||||
claim(blocks[index] ~= before,
|
||||
"the block was swapped once the wash ended")
|
||||
claim(phaseFrames > 0 and phaseFrames < 180,
|
||||
("the wash ended on its own after %d frames"):format(phaseFrames))
|
||||
U.wait(4)
|
||||
U.shot(game, out .. "/04-drained.png")
|
||||
|
||||
report()
|
||||
if fails > 0 then
|
||||
U.log("a FAIL above means the run is not showing what it claims to")
|
||||
end
|
||||
U.log("what right looks like: the whirlpool keeps spinning through the whole")
|
||||
U.log("surf wash and only pops out of the water when the sound has finished.")
|
||||
U.log("water where the whirlpool was while the wash is still playing is the")
|
||||
U.log("bug; a whirlpool still there after control comes back is the overshoot.")
|
||||
U.log("the pad is yours -- the other whirlpools are further south.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Eye check on NIGHT_SHADE's wavy screen (#1848). AnimationWavyScreen's
|
||||
-- `ld c, $ff` counts outer passes and the inner loop exits twice a frame
|
||||
-- (pokered engine/battle/animations.asm:1884-1903, 1916-1927), so the
|
||||
-- wobble runs ~128 frames with a 16-line wave, not 255 with a 32-line one.
|
||||
-- POKEPORT_DRIVER=tests/drivers/nightshade_anim_bug1848_test.lua POKEPORT_IDENTITY=bug1848 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local AnimPlayer = require("src.battle.AnimPlayer")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: open path south of the first grass
|
||||
-- patch; pokered data/maps/objects/Route1.asm parks its youngsters at
|
||||
-- (5, 24) and (15, 13), so nobody is standing here or watching.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0, so the flash that opens the animation is")
|
||||
U.log(" silent. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
if opts.animations == false then
|
||||
U.log("FAIL OPTION has animations off, which skips every queued anim row.")
|
||||
end
|
||||
check("battle animations are on", opts.animations ~= false)
|
||||
|
||||
local anims = (game.data.battle_anims or {}).moveAnims or {}
|
||||
check("NIGHT_SHADE has an animation", anims.NIGHT_SHADE ~= nil)
|
||||
|
||||
-- what the animation player budgets for the effect, ahead of the battle
|
||||
local probe = AnimPlayer.new(game.data.battle_anims)
|
||||
probe:start("NIGHT_SHADE", true)
|
||||
local budget
|
||||
for _, e in ipairs(probe.events) do
|
||||
if e.effect == "SE_WAVY_SCREEN" then budget = e.dur end
|
||||
end
|
||||
check(("SE_WAVY_SCREEN is budgeted %s frames (want 128)"):format(tostring(budget)),
|
||||
budget == 128)
|
||||
|
||||
local lead = Pokemon.new(game.data, "GASTLY", 25)
|
||||
lead.moves = { { id = "NIGHT_SHADE", pp = 15 } }
|
||||
game.save.party = { lead }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(15)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 6)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function waitPhase(phase, tries)
|
||||
for _ = 1, tries do
|
||||
if battle.phase == phase then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
return battle.phase == phase
|
||||
end
|
||||
check("the battle reached the menu", waitPhase("menu", 60))
|
||||
U.tap(game, "a")
|
||||
check("NIGHT_SHADE is the move on the list", waitPhase("moveSelect", 20))
|
||||
U.tap(game, "a")
|
||||
|
||||
-- ride the animation and time the wave from the fx layer itself
|
||||
local started, frames, phases = false, 0, {}
|
||||
local shots = { [4] = "start", [64] = "middle", [124] = "end" }
|
||||
for _ = 1, 400 do
|
||||
local wavy = battle.fx and battle.fx.wavy
|
||||
if wavy then
|
||||
started = true
|
||||
frames = frames + 1
|
||||
phases[#phases + 1] = wavy.phase
|
||||
local tag = shots[frames]
|
||||
if tag then
|
||||
U.shot(game, ("%s/bug1848_wavy_%s.png"):format(DIR, tag))
|
||||
end
|
||||
elseif started then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("the wave actually ran", started)
|
||||
check(("the wave lasted %d frames (want 128)"):format(frames),
|
||||
frames >= 120 and frames <= 136)
|
||||
local step = (#phases >= 2) and (phases[2] - phases[1]) or 0
|
||||
check(("the offset pointer advances %d entries a frame (want 2)"):format(step),
|
||||
step == 2)
|
||||
|
||||
U.log("Three shots are in " .. DIR .. ": bug1848_wavy_start/middle/end.png.")
|
||||
U.log("The screen should ripple with about nine full waves top to bottom,")
|
||||
U.log("scrolling upward, and settle after roughly two seconds. The near-miss")
|
||||
U.log("to watch for is half that many waves crawling for twice as long.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Party submenu box geometry (#1819) with PokemonMenuEntries in full (#1833).
|
||||
-- pokered anchors this box to the bottom row of the screen and grows it
|
||||
-- upward two rows per field move, indenting it by the widest field move name
|
||||
-- (engine/menus/text_box.asm:397-440, data/moves/field_moves.asm,
|
||||
-- data/text_boxes.asm:33).
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_submenu_box_bug1819_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Font = require("src.render.Font")
|
||||
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 mon = Pokemon.new(game.data, "CHANSEY", 40)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
-- record the boxes one real draw pass puts on screen; the submenu is the
|
||||
-- only one that does not start at column 0
|
||||
local function boxThisFrame()
|
||||
local seen = {}
|
||||
local real = Font.drawBox
|
||||
Font.drawBox = function(tx, ty, tw, th, ...)
|
||||
seen[#seen + 1] = { tx, ty, tw, th }
|
||||
return real(tx, ty, tw, th, ...)
|
||||
end
|
||||
U.wait(2)
|
||||
Font.drawBox = real
|
||||
for i = #seen, 1, -1 do
|
||||
if seen[i][1] > 0 then return seen[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function openSubmenu(moves)
|
||||
mon.moves = moves
|
||||
Screens.push(game, "PartyMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function closeSubmenu()
|
||||
U.tap(game, "b")
|
||||
U.wait(8)
|
||||
U.tap(game, "b")
|
||||
U.wait(8)
|
||||
end
|
||||
|
||||
local function move(id)
|
||||
local def = game.data.moves[id]
|
||||
return { id = id, pp = def and def.pp or 10 }
|
||||
end
|
||||
|
||||
-- tx, ty, tw, th the port should hand Font.drawBox, per case
|
||||
local cases = {
|
||||
{ name = "no field moves", shot = "bug1819_submenu_plain.png",
|
||||
moves = { move("POUND") }, rows = 3, box = { 11, 11, 9, 7 } },
|
||||
{ name = "one field move (STRENGTH indents to tile 10)",
|
||||
shot = "bug1819_submenu_strength.png",
|
||||
moves = { move("STRENGTH") }, rows = 4, box = { 9, 8, 11, 10 } },
|
||||
{ name = "SOFTBOILED, the widest name (tile 8)",
|
||||
shot = "bug1819_submenu_softboiled.png",
|
||||
moves = { move("SOFTBOILED") }, rows = 4, box = { 7, 8, 13, 10 } },
|
||||
}
|
||||
|
||||
for _, c in ipairs(cases) do
|
||||
local pm = openSubmenu(c.moves)
|
||||
local ok = pm and pm.submenu
|
||||
check(c.name .. ": the submenu opened", ok == true)
|
||||
if ok then
|
||||
local labels = {}
|
||||
for i, e in ipairs(pm.subItems) do labels[i] = e.label end
|
||||
U.log(c.name .. " lists:", table.concat(labels, " / "))
|
||||
check(c.name .. ": " .. c.rows .. " rows ending in CANCEL",
|
||||
#pm.subItems == c.rows
|
||||
and pm.subItems[#pm.subItems].action == "cancel")
|
||||
local b = boxThisFrame()
|
||||
if not b then
|
||||
check(c.name .. ": a submenu box was drawn", false)
|
||||
else
|
||||
U.log(c.name .. " box tx,ty,tw,th:", b[1], b[2], b[3], b[4])
|
||||
check(c.name .. ": box is " .. table.concat(c.box, ","),
|
||||
b[1] == c.box[1] and b[2] == c.box[2]
|
||||
and b[3] == c.box[3] and b[4] == c.box[4])
|
||||
check(c.name .. ": its bottom border is on tile row 17",
|
||||
b[2] + b[4] - 1 == 17)
|
||||
check(c.name .. ": its right edge is on tile column 19",
|
||||
b[1] + b[3] - 1 == 19)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/" .. c.shot)
|
||||
U.log("captured", SHOT_DIR .. "/" .. c.shot)
|
||||
end
|
||||
closeSubmenu()
|
||||
end
|
||||
|
||||
-- leave the last one up for the eye
|
||||
openSubmenu({ move("SOFTBOILED") })
|
||||
|
||||
U.log("The submenu on screen should sit flush against the bottom of the")
|
||||
U.log("screen, its lower border sharing the screen's last pixel row, with")
|
||||
U.log("SOFTBOILED / STATS / SWITCH / CANCEL inside it and one blank row")
|
||||
U.log("above SOFTBOILED. The near-miss to watch for is an 8px gap under")
|
||||
U.log("the box that shows the message box's own bottom line, so the two")
|
||||
U.log("borders read as a doubled or detached line.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -72,7 +72,11 @@ return function(game)
|
||||
check(howY == 112, "How many? sits on text-box first line (y=112)")
|
||||
check(textBox ~= nil, "bottom PrintText box drawn")
|
||||
check(qtyBox ~= nil, "quantity box at pokered row 9")
|
||||
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
|
||||
-- LIST_MENU_BOX 4,2 - 19,12: the fourth row's quantity sits at y=88
|
||||
-- (data/text_boxes.asm:13, engine/menus/players_pc.asm)
|
||||
check(maxItemY == 88, "last visible item row at y=88")
|
||||
check(textBox and maxItemY and maxItemY + 8 <= textBox.ty * 8,
|
||||
"last visible item row clears the bottom text box (#174)")
|
||||
if maxItemY and howY then
|
||||
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
-- The four PC list screens: the player's PC item lists (#1845) and Bill's
|
||||
-- PC mon lists plus CHANGE BOX (#1847). Both are LIST_MENU_BOX screens in
|
||||
-- the original (home/list_menu.asm:29-31, engine/menus/save.asm:437-506),
|
||||
-- not full-screen lists of their own.
|
||||
-- SHOT_DIR=/tmp/pc_lists POKEPORT_DRIVER=tests/drivers/pc_lists_bug1845_bug1847_test.lua POKEPORT_TOUCH=0 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 Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local PlayerPC = require("src.ui.PlayerPC")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if ok then U.log("PASS", label) else pass = false; U.log("FAIL", label) end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- record every Font.drawBox/Font.draw of one frame so the geometry can be
|
||||
-- asserted instead of eyeballed
|
||||
local function capture()
|
||||
local out = {}
|
||||
local savedDraw, savedBox = Font.draw, Font.drawBox
|
||||
Font.draw = function(text, x, y)
|
||||
out[#out + 1] = { kind = "text", text = tostring(text), x = x, y = y }
|
||||
return savedDraw(text, x, y)
|
||||
end
|
||||
Font.drawBox = function(tx, ty, tw, th)
|
||||
out[#out + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
|
||||
return savedBox(tx, ty, tw, th)
|
||||
end
|
||||
game.stack:draw()
|
||||
Font.draw, Font.drawBox = savedDraw, savedBox
|
||||
return out
|
||||
end
|
||||
|
||||
local function hasBox(drawn, tx, ty, tw, th)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "box" and d.tx == tx and d.ty == ty
|
||||
and d.tw == tw and d.th == th then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function textOnRow(drawn, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "text" and d.y == y and d.text ~= "" then return d end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hasText(drawn, want)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "text" and d.text == want then return d end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/ViridianPokecenter.asm keeps the floor east of
|
||||
-- the counter free; the PC itself is a hidden object, so the screens are
|
||||
-- pushed directly rather than bumped into
|
||||
local MAP, STAND = "VIRIDIAN_POKECENTER", { x = 13, y = 4 }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, "up")
|
||||
U.wait(6)
|
||||
local function freeNeighbour(ow)
|
||||
for dy = -2, 2 do
|
||||
for dx = -2, 2 do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
return cx, cy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local cx, cy = freeNeighbour(ow)
|
||||
if cx then
|
||||
U.log("stand cell blocked, standing on", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, "up")
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
|
||||
-- #1845: the player's PC item lists
|
||||
game.save.pcItems = { POTION = 3, ANTIDOTE = 1, REPEL = 5, ESCAPE_ROPE = 2 }
|
||||
game.stack:push(PlayerPC.new(game))
|
||||
U.wait(4)
|
||||
U.tap(game, "a") -- WITHDRAW ITEM
|
||||
U.wait(6)
|
||||
local list = game.stack:top()
|
||||
check("WITHDRAW ITEM opened a list", getmetatable(list) == ListMenu)
|
||||
if getmetatable(list) == ListMenu then
|
||||
check("the list is drawn as LIST_MENU_BOX, not a screen", list.itemBox == true)
|
||||
check("the PC menu underneath stays visible", list.isOpaque == false)
|
||||
check("PrintListMenuEntries' four rows", list.rows == 4)
|
||||
check("the list ends on the CANCEL terminator",
|
||||
list.items[#list.items].cancel == true)
|
||||
check("the withdraw prompt is up before anything is chosen",
|
||||
type(list.footer) == "string" and list.footer:find("withdraw", 1, true) ~= nil)
|
||||
local drawn = capture()
|
||||
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
|
||||
check("the prompt's text box at tile 0,12 (20x6)", hasBox(drawn, 0, 12, 20, 6))
|
||||
check("nothing on the invented title row (y=4)", textOnRow(drawn, 4) == nil)
|
||||
local name = hasText(drawn, list.items[1].label)
|
||||
check("first name at hlcoord 6,4 (48,32)",
|
||||
name ~= nil and name.x == 48 and name.y == 32)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1845_withdraw_item.png")
|
||||
U.tap(game, "b") -- back to the PC menu
|
||||
U.wait(4)
|
||||
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- LOG OFF
|
||||
U.wait(6)
|
||||
|
||||
-- #1847: Bill's PC mon lists and CHANGE BOX
|
||||
local boxes = Boxes.ensure(game.save)
|
||||
boxes[1] = {
|
||||
Pokemon.new(game.data, "PIDGEY", 12),
|
||||
Pokemon.new(game.data, "RATTATA", 7),
|
||||
Pokemon.new(game.data, "NIDORAN_M", 15),
|
||||
Pokemon.new(game.data, "ZUBAT", 9),
|
||||
Pokemon.new(game.data, "ONIX", 22),
|
||||
}
|
||||
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)
|
||||
U.tap(game, "a") -- WITHDRAW POKéMON
|
||||
U.wait(6)
|
||||
local mons = game.stack:top()
|
||||
check("WITHDRAW POKéMON opened a list", getmetatable(mons) == ListMenu)
|
||||
if getmetatable(mons) == ListMenu then
|
||||
check("the mon list is drawn as LIST_MENU_BOX", mons.itemBox == true)
|
||||
check("the mon list ends on CANCEL",
|
||||
mons.items[#mons.items].cancel == true)
|
||||
check("the level is a row of its own, not part of the name",
|
||||
mons.items[1].sub == ":L12"
|
||||
and mons.items[1].label:find(":L") == nil)
|
||||
local drawn = capture()
|
||||
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
|
||||
check("no invented BOX 1 (WITHDRAW) title",
|
||||
hasText(drawn, "BOX 1 (WITHDRAW)") == nil)
|
||||
local lvl = hasText(drawn, ":L12")
|
||||
check("PrintLevel one row down, 8 columns right (112,40)",
|
||||
lvl ~= nil and lvl.x == 112 and lvl.y == 40)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1847_withdraw_mon.png")
|
||||
U.tap(game, "b")
|
||||
U.wait(4)
|
||||
|
||||
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- CHANGE BOX
|
||||
U.wait(30)
|
||||
local ask = game.stack:top()
|
||||
check("CHANGE BOX asks before it shows the box list",
|
||||
getmetatable(ask) == TextBox)
|
||||
U.shot(game, DIR .. "/pc_1847_change_box_prompt.png")
|
||||
-- page through "When you change a POKéMON BOX..." and take the YES
|
||||
local function isPicker(s)
|
||||
return getmetatable(s) == Menu and s.kind == "pc_box_change"
|
||||
end
|
||||
for _ = 1, 12 do
|
||||
if isPicker(game.stack:top()) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
local picker = game.stack:top()
|
||||
check("YES opens the box picker", isPicker(picker))
|
||||
if isPicker(picker) then
|
||||
check("twelve boxes", #picker.items == 12)
|
||||
check("BoxNames spelling", picker.items[1].label == "BOX 1"
|
||||
and picker.items[12].label == "BOX12")
|
||||
check("the cursor starts on the current box",
|
||||
picker.index == game.save.currentBox)
|
||||
local drawn = capture()
|
||||
check("box list at tile 11,0 (9x14)", hasBox(drawn, 11, 0, 9, 14))
|
||||
check("BOX No. panel at tile 0,0 (11x4)", hasBox(drawn, 0, 0, 11, 4))
|
||||
local first = hasText(drawn, "BOX 1")
|
||||
check("BoxNames at hlcoord 13,1 (104,8)",
|
||||
first ~= nil and first.x == 104 and first.y == 8)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1847_change_box.png")
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
U.log("On screen now: the CHANGE BOX picker. BOX 1..BOX12 sit in a tall")
|
||||
U.log("box on the right, BOX No. 1 in a small panel top-left, and a")
|
||||
U.log("pokeball marks BOX 1 and BOX 3 only, the two that hold POKéMON.")
|
||||
U.log("The near-miss to watch for is a marker on all twelve boxes, or on")
|
||||
U.log("none, and BOX10-12 spelled with the space of BOX 1 to BOX 9.")
|
||||
U.log("Shots are in " .. DIR .. "; the two list shots must show the")
|
||||
U.log("PC menu and the What? box around a bordered list, never a white")
|
||||
U.log("page with a title row.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Eye/ear check for the Pokédex CONTENTS screen and entry page (#1829):
|
||||
-- screen furniture, the seven rows, the side-menu cursor, the cry the entry
|
||||
-- page waits on and the blinking page arrow.
|
||||
-- engine/menus/pokedex.asm:161-199, :242-273, :500-506; home/joypad2.asm:55-83.
|
||||
-- No POKEPORT_SPEED: the cry runs on the real-time audio clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pokedex_contents_bug1829_test.lua POKEPORT_IDENTITY=bug1829 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
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 opts = game.save.options or {}
|
||||
if (opts.sfxVol or 1) == 0 then
|
||||
U.log("WARNING sfxVol is 0, so the entry page's cry will be silent and")
|
||||
U.log("WARNING the pause before the height/weight lines will look like a hang")
|
||||
end
|
||||
|
||||
-- dex numbers -> species, so the driver never hardcodes an id
|
||||
local byDex = {}
|
||||
for _, def in pairs(game.data.pokemon) do
|
||||
if def.dex then byDex[def.dex] = def end
|
||||
end
|
||||
check("the cache carries a Kanto numbering", byDex[1] ~= nil and byDex[20] ~= nil)
|
||||
|
||||
game.save.pokedex = { seen = {}, owned = {} }
|
||||
for n = 1, 20 do
|
||||
local def = byDex[n]
|
||||
if def and n ~= 5 then game.save.pokedex.seen[def.id] = true end
|
||||
end
|
||||
for _, n in ipairs({ 1, 2, 3, 7, 8 }) do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
game.save.pokedex.seen[def.id] = true
|
||||
game.save.pokedex.owned[def.id] = true
|
||||
end
|
||||
end
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
|
||||
local dex = Screens.push(game, "PokedexMenu")
|
||||
U.wait(20)
|
||||
check("the dex list is on the stack", game.stack:top() == dex)
|
||||
check("the list stops at the highest seen number (20)", #dex.items == 20)
|
||||
check("dex 005 was left unseen, so its row is a dashed line",
|
||||
dex.items[5] and dex.items[5].name == "----------")
|
||||
check("dex 001 is owned, so its row carries the ball marker",
|
||||
dex.items[1] and dex.items[1].ball == true)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_contents.png")
|
||||
|
||||
-- Right pages the list without moving the cursor off its screen row
|
||||
local rowBefore = dex.index - dex.scroll
|
||||
U.tap(game, "right")
|
||||
U.wait(10)
|
||||
check("Right scrolled the list", dex.scroll == 7)
|
||||
check("the cursor kept its screen row", dex.index - dex.scroll == rowBefore)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_contents_paged.png")
|
||||
U.tap(game, "left")
|
||||
U.wait(10)
|
||||
check("Left paged back to the top", dex.scroll == 0)
|
||||
|
||||
-- A on an owned row opens the side menu: the cursor moves onto the
|
||||
-- DATA/CRY/AREA/QUIT block that was already on screen
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local side = game.stack:top()
|
||||
check("A opened the side menu", side ~= dex)
|
||||
check("the list row kept its hollow cursor", dex.hollowIndex == dex.index)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_side_menu.png")
|
||||
|
||||
-- DATA is the first row, so A again opens the entry page
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local page = game.stack:top()
|
||||
check("DATA opened the entry page", page ~= side and page.def ~= nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_entry_cry.png")
|
||||
|
||||
local waited = 0
|
||||
while page.crying and page:crying() and waited < 300 do
|
||||
waited = waited + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("the cry held the page for", waited, "frames")
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_entry_full.png")
|
||||
check("the entry has more than one page, so the arrow is drawn",
|
||||
(page.pageCount or 1) > 1)
|
||||
|
||||
local function shotAtBlink(target, path)
|
||||
for _ = 1, 120 do
|
||||
if (page.blink or 0) == target then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.shot(game, path)
|
||||
end
|
||||
shotAtBlink(8, SHOT_DIR .. "/bug1829_arrow_on.png")
|
||||
shotAtBlink(38, SHOT_DIR .. "/bug1829_arrow_off.png")
|
||||
|
||||
U.log("Shots are in " .. SHOT_DIR .. ". The CONTENTS screen should read")
|
||||
U.log("CONTENTS top left, a vertical rule down column 14 with SEEN and OWN")
|
||||
U.log("counts beside it and DATA/CRY/AREA/QUIT under them, and seven rows")
|
||||
U.log("of number-above-name with the ball left of the owned names.")
|
||||
U.log("bug1829_entry_cry should show the frame, name, kind, No. and pic")
|
||||
U.log("with nothing below the divider while the cry sounds; the height,")
|
||||
U.log("weight and description arrive in bug1829_entry_full once it ends.")
|
||||
U.log("The near-miss to watch for: all of it painted at once with the cry")
|
||||
U.log("playing over the top, and a page arrow that never blinks off")
|
||||
U.log("(bug1829_arrow_on has it, bug1829_arrow_off should not).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Rare Candy's "grew to level N!" line carries a jingle (#1846): pokered's
|
||||
-- RareCandyText is text_far _RareCandyText / sound_get_item_1 /
|
||||
-- text_promptbutton (engine/menus/party_menu.asm:289-293), and outside
|
||||
-- battle that command plays SFX_GET_ITEM_1 (home/text.asm:546).
|
||||
-- POKEPORT_DRIVER=tests/drivers/rare_candy_bug1846_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Sound = require("src.core.Sound")
|
||||
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
|
||||
|
||||
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
|
||||
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
|
||||
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "NIDORINO", 20)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "RARE_CANDY", 3, game.data)
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name, ...)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
|
||||
-- bag -> RARE CANDY -> USE -> the only party member
|
||||
Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local level = mon.level
|
||||
U.tap(game, "a")
|
||||
U.wait(40)
|
||||
|
||||
Sound.play = realPlay
|
||||
check("the candy raised the level", mon.level == level + 1)
|
||||
U.log("sounds heard:", table.concat(heard, " "))
|
||||
local jingle = false
|
||||
for _, name in ipairs(heard) do
|
||||
if name == "Get_Item1" then jingle = true end
|
||||
end
|
||||
check("the level line carried SFX_GET_ITEM_1", jingle)
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug1846_rare_candy.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1846_rare_candy.png")
|
||||
|
||||
U.log("NIDORINO grew to level 21 just now: the short item fanfare should")
|
||||
U.log("sound as that line finishes typing, and the box waits for A after")
|
||||
U.log("it. Silence there is the bug. The other near-miss is the longer")
|
||||
U.log("level-up fanfare, which only belongs to a candy used in battle.")
|
||||
U.log("Two candies are left in the bag to hear it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Manual check that Hideout B4F's Rocket3 engages on sight (#1814).
|
||||
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
|
||||
-- (scripts/RocketHideoutB4F.asm:95-96), so CheckSpriteCanSeePlayer engages him
|
||||
-- from the tile below. The LIFT KEY half (his after-battle talk still reveals
|
||||
-- the ball) is asserted in tests/parity_rocket3_sight_bug1814.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/rocket3_sight_bug1814_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red 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
|
||||
|
||||
-- pokered data/maps/objects/RocketHideoutB4F.asm: ROCKET3 stands at (11,2)
|
||||
-- facing DOWN, so the sight tile is the one below him
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
|
||||
|
||||
local function rocketIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.text == TEXT then return n end
|
||||
end
|
||||
end
|
||||
|
||||
-- a party that can take a hit, so the battle the sight starts is playable
|
||||
if #(game.save.party or {}) == 0 then
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "NIDOKING", 45) }
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, 13, 3, "left")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local rocket = rocketIn(ow)
|
||||
check("Rocket3 is on the map", rocket ~= nil)
|
||||
if rocket then
|
||||
U.log(("Rocket3 at (%d, %d) facing %s")
|
||||
:format(rocket.cellX, rocket.cellY, tostring(rocket.facing)))
|
||||
end
|
||||
|
||||
local header = rocket
|
||||
and game.data:trainerHeader(ow.map.def.label, rocket.def.index)
|
||||
check("his extracted view range is 1 tile", header and header.range == 1)
|
||||
|
||||
-- walk north into his line of sight; a map edit that moved him is a
|
||||
-- different failure than a sight check that never fires, so aim at the
|
||||
-- cell below wherever he actually stands
|
||||
local target = rocket and { rocket.cellX, rocket.cellY + 1 } or { 11, 3 }
|
||||
local steps = 0
|
||||
for _ = 1, 8 do
|
||||
local p = game.overworld.player
|
||||
if (p.cellX == target[1] and p.cellY == target[2])
|
||||
or game.overworld.engaging then break end
|
||||
if p.cellX > target[1] then U.hold(game, "left", 16)
|
||||
elseif p.cellX < target[1] then U.hold(game, "right", 16)
|
||||
elseif p.cellY > target[2] then U.hold(game, "up", 16)
|
||||
else U.hold(game, "down", 16) end
|
||||
steps = steps + 1
|
||||
U.wait(4)
|
||||
end
|
||||
U.log("walked", steps, "steps toward", target[1], target[2])
|
||||
|
||||
local sighted = false
|
||||
for _ = 1, 90 do
|
||||
ow = game.overworld
|
||||
if ow.engaging or ow.emote then sighted = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("stepping into his line of sight engages him", sighted)
|
||||
if game.overworld.emote then
|
||||
U.shot(game, SHOT_DIR .. "/bug1814_sighted.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1814_sighted.png")
|
||||
end
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
-- his line prints first and holds on a button wait (home/text_script.asm:96)
|
||||
local fought = false
|
||||
for _ = 1, 60 do
|
||||
if getmetatable(game.stack:top()) == BattleState then fought = true break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the walk-up runs into the battle", fought)
|
||||
if fought then
|
||||
U.wait(60)
|
||||
U.shot(game, SHOT_DIR .. "/bug1814_battle.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1814_battle.png")
|
||||
end
|
||||
|
||||
U.log("Right: the grunt notices the player one tile below him, the bubble")
|
||||
U.log("pops, he marches down and the fight starts on its own. The near-miss")
|
||||
U.log("is nothing happening until the player presses A at him, which is the")
|
||||
U.log("bug. After winning, talk to him again: he drops the LIFT KEY ball.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- Manual check of the Game Corner slot prompt, emote and menus (#1811).
|
||||
-- PromptUserToPlaySlots prints "Want to play?" and floats SMILE_BUBBLE over
|
||||
-- the player on the map, and only then loads the slot screen, whose bet menu
|
||||
-- is hlcoord 14,11 b=5 c=4 (engine/slots/slot_machine.asm:9-23, :83-86).
|
||||
-- Box geometry is asserted in tests/engine/slot_machine_boxes_bug1811.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/slots_prompt_bug1811_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SlotMachine = require("src.ui.SlotMachine")
|
||||
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 vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfx volume is 0 in options; raise it or the menu beeps are silent")
|
||||
end
|
||||
|
||||
-- AbleToPlaySlotsCheck wants a COIN CASE with coins in it
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.COIN_CASE = 1
|
||||
game.save.coins = math.max(game.save.coins or 0, 50)
|
||||
|
||||
-- the seats are hidden events on the machine tiles (data/events/
|
||||
-- hidden_events.asm StartSlotMachine); stand on any walkable neighbour
|
||||
local seats = (game.data.field.slotMachines or {}).GAME_CORNER or {}
|
||||
local placed = nil
|
||||
U.teleport(game, "GAME_CORNER", 9, 15, "down")
|
||||
local ow = game.overworld
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, seat in ipairs(seats) do
|
||||
if seat.state == "ok" then
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = seat.x + s[1], seat.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.teleport(game, "GAME_CORNER", cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
local fx, fy = ow.player:facingCell()
|
||||
if fx == seat.x and fy == seat.y then
|
||||
placed = { seat = seat, x = cx, y = cy, facing = s[3] }
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if placed then break end
|
||||
end
|
||||
check("standing at a working slot machine", placed ~= nil)
|
||||
if placed then
|
||||
U.log(("machine at (%d, %d), player at (%d, %d) facing %s")
|
||||
:format(placed.seat.x, placed.seat.y, placed.x, placed.y,
|
||||
placed.facing))
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A opens a text box, not the slot screen", isBox)
|
||||
check("the slot screen is not on the stack yet",
|
||||
getmetatable(top) ~= SlotMachine)
|
||||
if isBox then
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
U.log("box reads:", table.concat(shown, " / "))
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_prompt.png")
|
||||
|
||||
-- through the text, then YES
|
||||
for _ = 1, 8 do
|
||||
if game.overworld.emote then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
U.wait(4)
|
||||
local emote = game.overworld.emote
|
||||
check("YES floats an emotion bubble over the player", emote ~= nil)
|
||||
check("it is SMILE_BUBBLE, the third crop", emote and emote.bubble == 3)
|
||||
check("and it is over the player, not an NPC",
|
||||
emote and emote.npc == game.overworld.player)
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_smile.png")
|
||||
|
||||
for _ = 1, 120 do
|
||||
if getmetatable(game.stack:top()) == SlotMachine then break end
|
||||
U.wait(2)
|
||||
end
|
||||
local slots = game.stack:top()
|
||||
check("the slot screen opens after the bubble",
|
||||
getmetatable(slots) == SlotMachine)
|
||||
check("and it opens on the bet menu", slots.stage == "bet")
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_bet.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1811_{prompt,smile,bet}.png")
|
||||
|
||||
U.log("Right: the prompt box sits over the still-drawn Game Corner floor,")
|
||||
U.log("then a smiley pops over the player, then the machine appears with a")
|
||||
U.log("bet menu whose bottom edge is the last row of the screen. The")
|
||||
U.log("near-miss is the machine already drawn behind the prompt, or a bet")
|
||||
U.log("box two rows short of the bottom.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- #1831: spinner tiles whirled the sprite per DISPLAY frame and flickered
|
||||
-- the arrows at twice the cart's rate (engine/overworld/spinners.asm:1-22,
|
||||
-- home/overworld.asm:41-44,268-272).
|
||||
-- POKEPORT_DRIVER=tests/drivers/spinner_rate_bug1831_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1831 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
|
||||
end
|
||||
|
||||
-- data/generated/field.lua ROCKET_HIDEOUT_B2F: the arrow at (12, 9)
|
||||
-- slides 10 cells left, the longest straight ride on the floor.
|
||||
U.teleport(game, "ROCKET_HIDEOUT_B2F", 13, 9, "left")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("overworld is up on ROCKET_HIDEOUT_B2F", ow ~= nil and ow.player ~= nil)
|
||||
|
||||
-- one step left onto the arrow starts the forced slide
|
||||
U.hold(game, "left", 20)
|
||||
local spinning = false
|
||||
for _ = 1, 30 do
|
||||
if ow.player.spinning then spinning = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("stepping on the arrow starts the spin", spinning)
|
||||
|
||||
-- sample per fixed step while the slide runs
|
||||
local facings, timers, blurs = {}, {}, {}
|
||||
for _ = 1, 200 do
|
||||
if not ow.player.spinning then break end
|
||||
local _, _, _, facing = ow.player:pose()
|
||||
facings[#facings + 1] = facing
|
||||
timers[#timers + 1] = ow.player.spinTimer or 0
|
||||
blurs[#blurs + 1] = TileRenderer.spinBlurActive()
|
||||
U.wait(1)
|
||||
end
|
||||
check(("the slide gave %d samples (want >= 32)"):format(#facings),
|
||||
#facings >= 32)
|
||||
|
||||
-- spinTimer ticks once per fixed step, not per rendered frame
|
||||
local ticks = true
|
||||
for i = 2, #timers do
|
||||
if timers[i] - timers[i - 1] ~= 1 then ticks = false end
|
||||
end
|
||||
check("spinTimer advances exactly once per fixed step", ticks)
|
||||
|
||||
-- each facing holds for 2 fixed steps: one quarter-turn per OverworldLoop
|
||||
-- iteration, two frames each (home/overworld.asm:41-44)
|
||||
local runs, run = {}, 1
|
||||
for i = 2, #facings do
|
||||
if facings[i] == facings[i - 1] then
|
||||
run = run + 1
|
||||
else
|
||||
runs[#runs + 1] = run
|
||||
run = 1
|
||||
end
|
||||
end
|
||||
local twos, others = 0, 0
|
||||
for i = 2, #runs do -- the first run starts mid-phase, skip it
|
||||
if runs[i] == 2 then twos = twos + 1 else others = others + 1 end
|
||||
end
|
||||
check(("facing holds 2 steps (%d runs of 2, %d other)"):format(twos, others),
|
||||
twos >= 8 and others == 0)
|
||||
|
||||
-- arrow blur half-period is 16 frames: one whole 16-frame walked tile
|
||||
-- per wSimulatedJoypadStatesIndex parity (spinners.asm:18-22). The clock
|
||||
-- advances on the draw path, so allow one frame of sampling skew; the old
|
||||
-- bug read 8 here.
|
||||
local span, spans = 1, {}
|
||||
for i = 2, #blurs do
|
||||
if blurs[i] == blurs[i - 1] then
|
||||
span = span + 1
|
||||
else
|
||||
spans[#spans + 1] = span
|
||||
span = 1
|
||||
end
|
||||
end
|
||||
local good, bad = 0, 0
|
||||
for i = 2, #spans do -- first span starts mid-phase, skip it
|
||||
if spans[i] >= 15 and spans[i] <= 17 then good = good + 1
|
||||
else bad = bad + 1 end
|
||||
end
|
||||
check(("blur toggles every ~16 frames (%d good, %d off)"):format(good, bad),
|
||||
good >= 1 and bad == 0)
|
||||
|
||||
for _, line in ipairs(results) do U.log(line) end
|
||||
|
||||
U.log("Right looks like: while the player is swept along the arrow the")
|
||||
U.log("sprite makes roughly one full turn per 8 frames, a lazy whirl, and")
|
||||
U.log("the arrow tiles swap between blur and static about twice a second.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Manual check for #1866: black hairlines at the title's SGB zone boundaries
|
||||
-- (canvas rows 64 and 80, BlkPacket_Titlescreen, data/sgb/sgb_packets.asm:
|
||||
-- 123-127) at window heights that are not a whole multiple of 144.
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1866_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
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 title
|
||||
for _ = 1, 120 do
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" and top.sgbPalettes then
|
||||
title = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "start")
|
||||
U.wait(9)
|
||||
end
|
||||
check("the title screen is on top", title ~= nil)
|
||||
if not (title and title.sgbPalettes) then
|
||||
U.log("No title state to look at; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
local mode = opts and opts.colors or PaletteFX.mode
|
||||
if PaletteFX.mode ~= "gbc" then
|
||||
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(20)
|
||||
end
|
||||
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
|
||||
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
|
||||
|
||||
-- Every height below is odd and none is a multiple of 144, so the picture
|
||||
-- never lands on a whole number of screen pixels per GB pixel. "fill"
|
||||
-- additionally drops the integer fit scale, which is the shape the report's
|
||||
-- screenshot was taken in (about 5.98 screen pixels per GB pixel).
|
||||
local HEIGHTS = { 721, 823, 907, 1013 }
|
||||
local WIDTH = 1157
|
||||
local worstRows, worstTag = 0, "none"
|
||||
|
||||
-- the control: a zone list with a real hole in it, drawn the way the
|
||||
-- per-zone pass alone would draw it. It proves the sampler below can see
|
||||
-- a seam at all, and that the underpaint is what closes this one.
|
||||
local gapped = {}
|
||||
for i, z in ipairs(zones) do
|
||||
gapped[i] = { x = z.x, y = z.y, w = z.w, h = z.h, colors = z.colors }
|
||||
end
|
||||
if gapped[2] then
|
||||
gapped[2].y = gapped[2].y + 1
|
||||
gapped[2].h = gapped[2].h - 2
|
||||
end
|
||||
|
||||
local function drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
|
||||
local shader = PaletteFX.shader()
|
||||
love.graphics.setShader(shader)
|
||||
for _, z in ipairs(zoneList) do
|
||||
PaletteFX.sendColors(shader, z.colors)
|
||||
love.graphics.setScissor(uox + z.x * Ux, uoy + z.y * Uy,
|
||||
z.w * Ux, z.h * Uy)
|
||||
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
local function probeAt(ph, fill, zoneList, zonesOnly)
|
||||
local dpi = 1
|
||||
local probe = love.graphics.newCanvas(WIDTH, ph, { dpiscale = dpi })
|
||||
local Up
|
||||
if fill then
|
||||
Up = math.min(ph / 144, WIDTH / 160)
|
||||
else
|
||||
Up = math.max(1, math.floor(math.min(WIDTH / 160, ph / 144)))
|
||||
end
|
||||
local Ux, Uy = Up / dpi, Up / dpi
|
||||
local uox = math.floor((WIDTH - 160 * Up) / 2) / dpi
|
||||
local uoy = math.floor((ph - 144 * Up) / 2) / dpi
|
||||
local uvpw, uvph = 160 * Ux, 144 * Uy
|
||||
love.graphics.setCanvas(probe)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if zonesOnly then
|
||||
drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
|
||||
else
|
||||
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zoneList, Ux, Uy,
|
||||
uox, uoy, uox, uoy, uvpw, uvph, dpi, dpi)
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
local data = probe:newImageData()
|
||||
local top = math.floor(uoy * dpi) + 1
|
||||
local bottom = math.min(math.floor((uoy + uvph) * dpi) - 1, data:getHeight() - 1)
|
||||
local col = math.floor(uox * dpi) + 2
|
||||
local rows = {}
|
||||
for py = top, bottom do
|
||||
local r, g, b = data:getPixel(col, py)
|
||||
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
|
||||
end
|
||||
return rows, ("%dx%d %s"):format(WIDTH, ph, fill and "fill" or "fit")
|
||||
end
|
||||
|
||||
for _, ph in ipairs(HEIGHTS) do
|
||||
for _, fill in ipairs({ false, true }) do
|
||||
local rows, tag = probeAt(ph, fill, zones, false)
|
||||
if #rows > worstRows then worstRows, worstTag = #rows, tag end
|
||||
check(tag .. ": no letterbox row shows through a zone boundary",
|
||||
#rows == 0)
|
||||
if #rows > 0 then
|
||||
U.log(" black rows:", table.concat(rows, ","))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("worst case:", worstRows, "black rows at", worstTag)
|
||||
|
||||
local holeRows = probeAt(HEIGHTS[3], true, gapped, true)
|
||||
check("a zone list with a hole in it does show the letterbox clear",
|
||||
#holeRows > 0)
|
||||
local filledRows = probeAt(HEIGHTS[3], true, gapped, false)
|
||||
check("and the same hole drawn through the renderer shows none",
|
||||
#filledRows == 0)
|
||||
|
||||
-- and the live window at one of those heights, for the eye
|
||||
local ok = pcall(love.window.setMode, WIDTH, HEIGHTS[3],
|
||||
{ resizable = true, vsync = 0 })
|
||||
check("the window resized to " .. WIDTH .. "x" .. HEIGHTS[3], ok)
|
||||
U.wait(20)
|
||||
check("wrote " .. SHOT_DIR .. "/bug1866_title_907.png",
|
||||
U.shot(game, SHOT_DIR .. "/bug1866_title_907.png"))
|
||||
pcall(love.window.setMode, WIDTH, HEIGHTS[4], { resizable = true, vsync = 0 })
|
||||
U.wait(20)
|
||||
check("wrote " .. SHOT_DIR .. "/bug1866_title_1013.png",
|
||||
U.shot(game, SHOT_DIR .. "/bug1866_title_1013.png"))
|
||||
|
||||
PaletteFX.setMode(mode)
|
||||
U.wait(10)
|
||||
|
||||
U.log("Look along the title picture where the POKeMON logo meets the red")
|
||||
U.log("VERSION ribbon, and where the ribbon meets RED and the copyright: the")
|
||||
U.log("off-white background must run through both boundaries unbroken.")
|
||||
U.log("The near-miss is a hairline one or two pixels tall that is dark grey")
|
||||
U.log("or slightly off-white rather than black; that is still the seam.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Vitamins (#1805): pokered's .useVitamin plays SFX_HEAL_AILMENT, then
|
||||
-- prints VitaminStatRoseText over the still-drawn party menu and ends at
|
||||
-- RemoveUsedItem, which only strips the item from the bag
|
||||
-- (engine/items/item_effects.asm:1313-1322, :2274).
|
||||
-- POKEPORT_DRIVER=tests/drivers/vitamin_bug1805_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Sound = require("src.core.Sound")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
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
|
||||
|
||||
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
|
||||
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
|
||||
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "NIDORINO", 20)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "HP_UP", 3, game.data)
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name, ...)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
|
||||
-- bag -> HP UP -> USE -> the only party member
|
||||
Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
Sound.play = realPlay
|
||||
check("the vitamin was consumed", (game.save.inventory.HP_UP or 0) == 2)
|
||||
check("HP stat exp went up", (mon.statExp and mon.statExp.hp or 0) == 2560)
|
||||
U.log("sounds heard:", table.concat(heard, " "))
|
||||
local jingle = false
|
||||
for _, name in ipairs(heard) do
|
||||
if name == "Heal_Ailment" then jingle = true end
|
||||
end
|
||||
check("the stat line carried SFX_HEAL_AILMENT", jingle)
|
||||
|
||||
local partyUp = false
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if getmetatable(s) == PartyMenu then partyUp = true end
|
||||
end
|
||||
check("the party menu is still drawn under the message", partyUp)
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug1805_vitamin.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1805_vitamin.png")
|
||||
|
||||
U.log("NIDORINO's HEALTH rose just now: the short cure jingle should sound")
|
||||
U.log("as that line types, with the party list still on screen behind the")
|
||||
U.log("box, and only then does the menu come down. The bug was silence")
|
||||
U.log("plus the list vanishing to the map the instant A was pressed.")
|
||||
U.log("Two HP UPs are left in the bag to try it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
-- data/moves/animations.asm:401-403
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
||||
|
||||
local runner = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
|
||||
T.eq(runner.animId, "ANIM_THROW_POKE_BALL",
|
||||
"the runner exposes the anim id BattleState latches on")
|
||||
T.eq(runner.env.animId, "ANIM_THROW_POKE_BALL",
|
||||
"the object/bg env still sees the same id")
|
||||
|
||||
-- A real runner skipped with B must still hide the caught mon.
|
||||
do
|
||||
local s = setmetatable({
|
||||
anim = runner,
|
||||
ballThrow = { caught = true },
|
||||
picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, true, "a B-skipped catch latches with a real runner")
|
||||
end
|
||||
|
||||
do
|
||||
local free = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
|
||||
local s = setmetatable({
|
||||
anim = free,
|
||||
ballThrow = { caught = false },
|
||||
picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, false, "a B-skipped break-free still does not latch")
|
||||
end
|
||||
|
||||
T.finish("gen2 ball anim id bug 1804")
|
||||
@@ -0,0 +1,67 @@
|
||||
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
|
||||
-- pokegold constants/engine_flags.asm:65-91, in const_def order from
|
||||
-- ENGINE_RADIO_CARD = 0: PALLET 52 .. INDIGO_PLATEAU 63, NEW_BARK 64 ..
|
||||
-- SILVER_CAVE 75.
|
||||
local GOLD_IDS = {
|
||||
SPAWN_PALLET = 52, SPAWN_VIRIDIAN = 53, SPAWN_PEWTER = 54,
|
||||
SPAWN_CERULEAN = 55, SPAWN_ROCK_TUNNEL = 56, SPAWN_VERMILION = 57,
|
||||
SPAWN_LAVENDER = 58, SPAWN_SAFFRON = 59, SPAWN_CELADON = 60,
|
||||
SPAWN_FUCHSIA = 61, SPAWN_CINNABAR = 62, SPAWN_INDIGO = 63,
|
||||
SPAWN_NEW_BARK = 64, SPAWN_CHERRYGROVE = 65, SPAWN_VIOLET = 66,
|
||||
SPAWN_AZALEA = 67, SPAWN_CIANWOOD = 68, SPAWN_GOLDENROD = 69,
|
||||
SPAWN_OLIVINE = 70, SPAWN_ECRUTEAK = 71, SPAWN_MAHOGANY = 72,
|
||||
SPAWN_LAKE_OF_RAGE = 73, SPAWN_BLACKTHORN = 74, SPAWN_MT_SILVER = 75,
|
||||
}
|
||||
|
||||
local function byFlag()
|
||||
local out = {}
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS) do out[row.spawn] = row.flag end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A Gold cache carries no engineFlagOrder; the table keeps its literal ids.
|
||||
FieldMoves.bindEngineFlags(nil)
|
||||
local gold = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(gold[spawn], id, spawn .. " keeps the pokegold id with no order")
|
||||
end
|
||||
|
||||
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM at index 16
|
||||
-- shifts every later id up exactly one.
|
||||
local crystalOrder = {}
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS) do
|
||||
-- order[i] names id i-1, so the Crystal id (gold + 1) sits at gold + 2
|
||||
crystalOrder[GOLD_IDS[row.spawn] + 2] = row.name
|
||||
end
|
||||
FieldMoves.bindEngineFlags(crystalOrder)
|
||||
local crystal = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(crystal[spawn], id + 1, spawn .. " rebinds one higher under Crystal")
|
||||
end
|
||||
|
||||
-- The Kanto gate reads SPAWN_INDIGO through the rebound id: Crystal's 64,
|
||||
-- which the old table misread as Gold's SPAWN_NEW_BARK.
|
||||
local save = { engineFlags = { [64] = true } }
|
||||
T.check(FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO"),
|
||||
"Crystal flag 64 is the INDIGO_PLATEAU flypoint")
|
||||
local kanto = FieldMoves.flyPoints(save, nil, "kanto")
|
||||
T.eq(#kanto, 1, "the Kanto half opens on it")
|
||||
T.eq(kanto[1].spawn, "SPAWN_INDIGO", "with the plateau row itself")
|
||||
|
||||
-- And a rebind back to a Gold cache restores the literal ids.
|
||||
FieldMoves.bindEngineFlags(nil)
|
||||
local again = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(again[spawn], id, spawn .. " returns to the pokegold id")
|
||||
end
|
||||
local goldSave = { engineFlags = { [63] = true } }
|
||||
T.check(FieldMoves.hasVisitedSpawn(goldSave, "SPAWN_INDIGO"),
|
||||
"Gold flag 63 is INDIGO_PLATEAU again")
|
||||
|
||||
T.finish("gen2 flypoint rebind bug 1836")
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:105-158
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Assets = require("src.render.Assets")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local FRONT = "assets/generated/battle/front/cyndaquil.png"
|
||||
local SHEET = "assets/generated/battle/anim/cyndaquil.png"
|
||||
local MOD_FRONT = "mods/skin/overrides/battle/front/cyndaquil.png"
|
||||
local MOD_SHEET = "mods/skin/overrides/battle/anim/cyndaquil.png"
|
||||
|
||||
local image = { getDimensions = function() return 40, 200 end }
|
||||
Assets.image = function() return image end
|
||||
|
||||
local overrides = {}
|
||||
Assets.resolve = function(path) return overrides[path] or path end
|
||||
|
||||
local mon = { species = "CYNDAQUIL" }
|
||||
local anim = { sheet = SHEET, tiles = 5, play = { { 1 } } }
|
||||
|
||||
local function newSelf(picPath)
|
||||
local self = setmetatable({
|
||||
pokemon = { CYNDAQUIL = { spriteFront = FRONT, anim = anim } },
|
||||
picCache = {},
|
||||
}, { __index = UI })
|
||||
if picPath then
|
||||
self.pic = function() return image, false, picPath end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Stock cache, no mod: the sheet still animates.
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.check(s.frontAnim ~= nil, "an unmodded Crystal front pic still animates")
|
||||
T.eq(s.frontAnim and s.frontAnim.sheet, image, "off the extracted sheet")
|
||||
end
|
||||
|
||||
-- Gold and Silver have no anim row at all and never reach any of this.
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf()
|
||||
s.pokemon.CYNDAQUIL.anim = nil
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "a cache with no anim row is untouched")
|
||||
s.pokemon.CYNDAQUIL.anim = anim
|
||||
end
|
||||
|
||||
-- The bug: the static pic replaced, the sheet left stock. Holding the static
|
||||
-- pic for the whole scene is the only way not to flicker stock art over it.
|
||||
do
|
||||
overrides = { [FRONT] = MOD_FRONT }
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "an overridden front pic suppresses the stock frames")
|
||||
end
|
||||
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf(MOD_FRONT)
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "a pokemon.sprite path also suppresses them")
|
||||
end
|
||||
|
||||
-- A mod that ships both halves keeps its animation.
|
||||
do
|
||||
overrides = { [FRONT] = MOD_FRONT, [SHEET] = MOD_SHEET }
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.check(s.frontAnim ~= nil, "a mod that ships a sheet as well still animates")
|
||||
end
|
||||
|
||||
T.finish("gen2 front anim mod sheet bug 1827")
|
||||
@@ -0,0 +1,72 @@
|
||||
-- engine/gfx/color.asm:342-359 / engine/gfx/cgb_layouts.asm:167-192
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
|
||||
local printed, through
|
||||
local realPrint, realThrough = Chrome.print, Chrome.printThrough
|
||||
Chrome.print = function(text, tx, ty)
|
||||
printed[#printed + 1] = { text = text, x = tx, y = ty }
|
||||
return 0
|
||||
end
|
||||
Chrome.printThrough = function(text, tx, ty, palette)
|
||||
through[#through + 1] = { text = text, x = tx, y = ty, palette = palette }
|
||||
return 0
|
||||
end
|
||||
|
||||
local function fake(page, placements)
|
||||
local self = setmetatable({ page = page, mon = {}, party = {} },
|
||||
{ __index = SummaryMenu })
|
||||
self.greenPlacements = function() return placements end
|
||||
self.bluePlacements = function() return placements end
|
||||
self.pinkPlacements = function() return placements end
|
||||
self.drawVerticalDivider = function() end
|
||||
return self
|
||||
end
|
||||
|
||||
local rows = {
|
||||
{ text = "ITEM", x = 0, y = 8 },
|
||||
{ text = "MOVE", x = 0, y = 10 },
|
||||
}
|
||||
|
||||
for _, page in ipairs({ SummaryMenu.PINK_PAGE, SummaryMenu.GREEN_PAGE,
|
||||
SummaryMenu.BLUE_PAGE }) do
|
||||
local self = fake(page, rows)
|
||||
printed, through = {}, {}
|
||||
if page == SummaryMenu.GREEN_PAGE then
|
||||
self:drawGreenPage()
|
||||
elseif page == SummaryMenu.BLUE_PAGE then
|
||||
self:drawBluePage()
|
||||
else
|
||||
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
|
||||
end
|
||||
T.eq(#printed, 0, "no lower-half string prints on the white default paper")
|
||||
T.eq(#through, #rows, "every lower-half string prints through a palette")
|
||||
local tint = SummaryMenu.PAGE_TINTS[page]
|
||||
for _, call in ipairs(through) do
|
||||
T.eq(call.palette and call.palette[1][1], tint[1], "paper red is the page tint")
|
||||
T.eq(call.palette and call.palette[1][2], tint[2], "paper green is the page tint")
|
||||
T.eq(call.palette and call.palette[1][3], tint[3], "paper blue is the page tint")
|
||||
T.eq(call.palette and call.palette[4][1], 0, "ink stays black")
|
||||
end
|
||||
end
|
||||
|
||||
-- Rows 0-7 are filled with palette $1, whose colour 0 is white, so the upper
|
||||
-- half keeps the default paper.
|
||||
do
|
||||
local self = fake(SummaryMenu.PINK_PAGE, rows)
|
||||
printed, through = {}, {}
|
||||
self:drawPlacements(rows)
|
||||
T.eq(#printed, #rows, "upper-half strings still take the default paper")
|
||||
T.eq(#through, 0, "upper-half strings do not force a page tint")
|
||||
end
|
||||
|
||||
Chrome.print, Chrome.printThrough = realPrint, realThrough
|
||||
|
||||
T.finish("gen2 stats text tint bug 1858")
|
||||
@@ -0,0 +1,56 @@
|
||||
-- engine/events/overworld.asm:1150-1165, engine/events/field_moves.asm:5-10
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Sound = require("src.core.Sound")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local busy = false
|
||||
Sound.sfxBusy = function() return busy end
|
||||
|
||||
local log = {}
|
||||
local world = setmetatable({}, { __index = World })
|
||||
world.setNickname = function() end
|
||||
world.showText = function(_, _, done) if done then done() end end
|
||||
world.playSfxNamed = function() log[#log + 1] = "sfx" end
|
||||
world.replaceBlock = function(_, index, blockId)
|
||||
log[#log + 1] = "block:" .. tostring(index) .. ":" .. tostring(blockId)
|
||||
return true
|
||||
end
|
||||
|
||||
world:runWhirlpool({ text = "USED WHIRLPOOL", blockIndex = 7, replacement = 3 })
|
||||
T.eq(#log, 0, "the box closing does not swap the block yet")
|
||||
T.eq(world.fieldMove and world.fieldMove.phase, "whirlpoolsfx",
|
||||
"closing the box parks the WaitSFX phase")
|
||||
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "the first WaitSFX passes and SFX_SURF starts")
|
||||
|
||||
busy = true
|
||||
world:updateFieldMove()
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "the whirlpool is still on screen while the sfx plays")
|
||||
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx", "block:7:3" },
|
||||
"the block is swapped once the second WaitSFX clears")
|
||||
T.eq(world.fieldMove, nil, "and the phase is done")
|
||||
|
||||
-- The callback path (DisappearWhirlpool via callasm) carries no block, and
|
||||
-- must not invent one.
|
||||
do
|
||||
log = {}
|
||||
world.fieldMove = nil
|
||||
world:playWhirlpoolSound()
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "a bare sound call swaps nothing")
|
||||
end
|
||||
|
||||
T.finish("gen2 whirlpool block after sfx bug 1862")
|
||||
@@ -0,0 +1,105 @@
|
||||
-- A link switch runs SwitchPlayerMon on the switcher and SwitchEnemyMon on
|
||||
-- the peer (core.asm:2418-2422, trainer_ai.asm:596-599) (#1562).
|
||||
-- luajit tests/run_engine.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Link = require("tests.modkit.link")
|
||||
local Net = require("src.link.Net")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
|
||||
math.randomseed(4242)
|
||||
local Input = Link.prepare(Data)
|
||||
|
||||
local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" })
|
||||
local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" })
|
||||
|
||||
local netA, netB = Net.loopbackPair()
|
||||
local packedA = Protocol.packParty(gameA.save.party)
|
||||
local packedB = Protocol.packParty(gameB.save.party)
|
||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||
myParty = packedA, theirParty = packedB,
|
||||
theirName = "BLUE", seed = 987654321,
|
||||
})
|
||||
local battleB = LinkBattle.newGuest(gameB, netB, {
|
||||
myParty = packedB, theirParty = packedA,
|
||||
theirName = "RED", seed = 987654321,
|
||||
})
|
||||
|
||||
local function watch(battle)
|
||||
local seen = {}
|
||||
local sayNext, sayNextAuto = battle.sayNext, battle.sayNextAuto
|
||||
battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end
|
||||
battle.sayNextAuto = function(s, text, d)
|
||||
seen[#seen + 1] = text
|
||||
return sayNextAuto(s, text, d)
|
||||
end
|
||||
return seen
|
||||
end
|
||||
local seenA, seenB = watch(battleA), watch(battleB)
|
||||
|
||||
local function count(list, needle)
|
||||
local n = 0
|
||||
for _, text in ipairs(list) do
|
||||
if text and text:find(needle, 1, true) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
local function firstIndex(list, needle)
|
||||
for i, text in ipairs(list) do
|
||||
if text and text:find(needle, 1, true) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
local resA, resB
|
||||
battleA.onFinish = function(r) resA = r end
|
||||
battleB.onFinish = function(r) resB = r end
|
||||
gameA.stack:push(battleA)
|
||||
gameB.stack:push(battleB)
|
||||
|
||||
local switched, retreatSeen = false, false
|
||||
for _ = 1, 60000 do
|
||||
if resA and resB then break end
|
||||
Input.pressed = { a = true }
|
||||
if not switched and battleA.phase == "menu" and battleA.playerParty[2] then
|
||||
battleA:resolveSwitch(battleA.playerParty[2])
|
||||
switched = true
|
||||
end
|
||||
for _, g in ipairs({ gameA, gameB }) do
|
||||
local top = g.stack:top()
|
||||
if top and top.forceSwitch and top.party then
|
||||
for i, mon in ipairs(top.party) do
|
||||
if mon.hp > 0 then top.index = i break end
|
||||
end
|
||||
end
|
||||
g.stack:update(1 / 60)
|
||||
end
|
||||
if battleA.shrinkOut then retreatSeen = true end
|
||||
end
|
||||
|
||||
T.check(switched, "the host got a menu phase to switch from")
|
||||
T.eq(count(seenA, "Come back!"), 1,
|
||||
"RetreatMon prints once, for the voluntary switch only")
|
||||
T.check(retreatSeen, "AnimateRetreatingPlayerMon runs on the switcher")
|
||||
T.eq(count(seenB, "drew"), 1, "the peer prints AIBattleWithdrawText once")
|
||||
|
||||
local withdrawA = firstIndex(seenA, "Come back!")
|
||||
local sendA = firstIndex(seenA, "Go! ") or firstIndex(seenA, "Do it! ")
|
||||
or firstIndex(seenA, "Get'm! ")
|
||||
T.check(sendA ~= nil and withdrawA < sendA, "the recall line comes before the send-out")
|
||||
local withdrawB = firstIndex(seenB, "drew")
|
||||
local sendB = firstIndex(seenB, "sent\nout")
|
||||
T.check(sendB ~= nil and withdrawB < sendB, "and so does the peer's withdraw line")
|
||||
|
||||
T.check(battleA.localHashes ~= nil and battleB.localHashes ~= nil,
|
||||
"both sides still record per-turn hashes")
|
||||
local mismatch
|
||||
for turn, hash in pairs(battleA.localHashes) do
|
||||
local other = battleB.localHashes[turn]
|
||||
if other and other ~= hash then mismatch = mismatch or turn end
|
||||
end
|
||||
T.check(mismatch == nil, "the added lines leave the lockstep state hash alone")
|
||||
|
||||
T.finish("link switch prints the recall lines (#1562)")
|
||||
@@ -5,7 +5,7 @@
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm, called from
|
||||
-- start_sub_menus.asm) walks wPartyMon1Moves in slot order -- so a mon
|
||||
-- with STRENGTH in slot 3 and SURF in slot 4 shows STRENGTH then SURF on
|
||||
-- top, with STATS/SWITCH closing the list. The port used to build the
|
||||
-- top, with STATS/SWITCH/CANCEL closing the list. The port used to build the
|
||||
-- submenu as STATS/SWITCH first and tack the field moves on the bottom.
|
||||
-- ROM-free: drives the real PartyMenu over stub game state.
|
||||
-- luajit tests/engine/party_fieldmove_order_bug792.lua
|
||||
@@ -74,7 +74,8 @@ local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
openSubmenu(pm)
|
||||
check(pm.submenu, "A on a party mon opens the submenu")
|
||||
same(actions(pm.subItems), { "strength", "surf", "stats", "switch" },
|
||||
same(actions(pm.subItems),
|
||||
{ "strength", "surf", "stats", "switch", "cancel" },
|
||||
"field moves sit above STATS/SWITCH in move-list order (#792)")
|
||||
eq(pm.subItems[1].label, "STRENGTH", "slot 3's STRENGTH leads the submenu")
|
||||
eq(pm.subItems[2].label, "SURF", "slot 4's SURF follows it")
|
||||
@@ -84,8 +85,8 @@ local plain = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local pm2 = PartyMenu.new(plain, {})
|
||||
plain.stack:push(pm2)
|
||||
openSubmenu(pm2)
|
||||
same(actions(pm2.subItems), { "stats", "switch" },
|
||||
"no field moves: the submenu is just STATS/SWITCH")
|
||||
same(actions(pm2.subItems), { "stats", "switch", "cancel" },
|
||||
"no field moves: the submenu is just PokemonMenuEntries")
|
||||
|
||||
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
|
||||
-- still lists both HMs, and .outOfBattleMovePointers refuses on
|
||||
@@ -95,7 +96,8 @@ local noBadges = newGame(
|
||||
local pm3 = PartyMenu.new(noBadges, {})
|
||||
noBadges.stack:push(pm3)
|
||||
openSubmenu(pm3)
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
|
||||
same(actions(pm3.subItems),
|
||||
{ "strength", "surf", "stats", "switch", "cancel" },
|
||||
"the HM moves are listed without the badges (#1022)")
|
||||
|
||||
T.finish("party_fieldmove_order_bug792")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Out of battle the per-mon submenu ends STATS/SWITCH/CANCEL: pokered
|
||||
-- prints one string, PokemonMenuEntries (engine/menus/text_box.asm:504-507),
|
||||
-- under whatever field moves the mon has, and start_sub_menus.asm:71-75
|
||||
-- treats the last row as a real selection that leaves the party menu
|
||||
-- (.exitMenu, start_sub_menus.asm:26-30) where B only returns to the list.
|
||||
-- The port stopped at SWITCH (#1833). ROM-free: drives the real PartyMenu
|
||||
-- over stub game state.
|
||||
-- luajit tests/engine/party_submenu_cancel_bug1833.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function newGame(moves)
|
||||
local game = {
|
||||
data = { pokemon = { LAPRAS = { name = "LAPRAS" } } },
|
||||
save = {
|
||||
party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 },
|
||||
level = 30, moves = moves } },
|
||||
inventory = {}, options = {}, flags = {},
|
||||
},
|
||||
overworld = { map = { def = { tileset = "OVERWORLD" },
|
||||
id = "PALLET_TOWN" },
|
||||
dark = false },
|
||||
}
|
||||
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() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(pm, btn)
|
||||
pm.game.input.queue = { [btn] = true }
|
||||
pm:update(1 / 60)
|
||||
pm.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function actions(items)
|
||||
local out = {}
|
||||
for i, item in ipairs(items or {}) do out[i] = item.action end
|
||||
return out
|
||||
end
|
||||
|
||||
local game = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local cancelled = false
|
||||
local pm = PartyMenu.new(game, { onCancel = function() cancelled = true end })
|
||||
game.stack:push(pm)
|
||||
press(pm, "a")
|
||||
check(pm.submenu, "A on a party mon opens the submenu")
|
||||
same(actions(pm.subItems), { "stats", "switch", "cancel" },
|
||||
"the plain list is PokemonMenuEntries in full (#1833)")
|
||||
eq(pm.subItems[3].label, "CANCEL", "CANCEL closes the list")
|
||||
|
||||
-- CANCEL sits at the bottom, so one wrap upward reaches it
|
||||
press(pm, "up")
|
||||
eq(pm.subIndex, 3, "up from STATS wraps onto CANCEL")
|
||||
press(pm, "a")
|
||||
eq(#game.stack.states, 0, "choosing CANCEL leaves the party menu (.exitMenu)")
|
||||
check(cancelled, "and reports the cancel to the caller")
|
||||
|
||||
-- B out of the submenu is the other path: back to the party list, menu up
|
||||
local game2 = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local pm2 = PartyMenu.new(game2, {})
|
||||
game2.stack:push(pm2)
|
||||
press(pm2, "a")
|
||||
press(pm2, "b")
|
||||
check(not pm2.submenu, "B closes the submenu")
|
||||
eq(#game2.stack.states, 1, "B keeps the party menu up (start_sub_menus.asm:68-69)")
|
||||
|
||||
-- field moves still lead, with the three fixed rows under them
|
||||
local game3 = newGame({ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
|
||||
local pm3 = PartyMenu.new(game3, {})
|
||||
game3.stack:push(pm3)
|
||||
press(pm3, "a")
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch", "cancel" },
|
||||
"field moves sit above the whole of PokemonMenuEntries")
|
||||
|
||||
-- the in-battle list is its own three-row template and is unchanged
|
||||
local game4 = newGame({ { id = "SURF", pp = 15 } })
|
||||
local pm4 = PartyMenu.new(game4, { battle = {}, onSwitch = function() end })
|
||||
game4.stack:push(pm4)
|
||||
press(pm4, "a")
|
||||
same(actions(pm4.subItems), { "battle_switch", "stats", "cancel" },
|
||||
"battle keeps SwitchStatsCancelText's order (data/text_boxes.asm:33)")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,127 @@
|
||||
-- The Pokédex list was a generic ListMenu with a title and a footer instead
|
||||
-- of the cart's CONTENTS screen, and Left/Right dragged the cursor with the
|
||||
-- page (#1829).
|
||||
-- engine/menus/pokedex.asm:161-199 (furniture), :242-273 (rows), :314-342
|
||||
-- (Left/Right move wListScrollOffset only), :208-222 (wDexMaxSeenMon).
|
||||
-- luajit tests/engine/pokedex_contents_bug1829.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")
|
||||
|
||||
-- stub Font: the screen draws through Font.draw/Font.drawCode only, and the
|
||||
-- real Font needs loaded page images this suite has no reason to touch.
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
|
||||
width = function(text) return #text * 8 end,
|
||||
split = function(text)
|
||||
local out = {}
|
||||
for i = 1, #text do out[i] = { from = i, to = i } end
|
||||
return out
|
||||
end,
|
||||
}
|
||||
|
||||
local PokedexMenu = require("src.ui.PokedexMenu")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function hasText(text, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.text == text and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function hasCode(code, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.code == code and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local DEX_SIZE = 151
|
||||
local data = {
|
||||
pokemon = {},
|
||||
constants = { dexSize = DEX_SIZE, dexDigits = 3 },
|
||||
}
|
||||
for n = 1, DEX_SIZE do
|
||||
local id = ("DEXMON_%03d"):format(n)
|
||||
data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n }
|
||||
end
|
||||
|
||||
-- seen 1..20, owned 1..3, so the list stops at 20 and row 2 carries a ball
|
||||
local function newGame()
|
||||
local save = { pokedex = { seen = {}, owned = {} } }
|
||||
for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end
|
||||
for n = 1, 3 do save.pokedex.owned[("DEXMON_%03d"):format(n)] = true end
|
||||
return { data = data, save = save,
|
||||
stack = { push = function() end, pop = function() end,
|
||||
top = function() end } }
|
||||
end
|
||||
|
||||
local dex = PokedexMenu.new(newGame(), {})
|
||||
|
||||
-- ------------------------------------------------ wDexMaxSeenMon caps it
|
||||
eq(#dex.items, 20, "the list stops at the highest seen number, not at 151")
|
||||
eq(dex.items[20].name, "MON020", "the last row is the highest seen species")
|
||||
|
||||
-- ------------------------------------------------ Left / Right scroll only
|
||||
dex.index, dex.scroll = 3, 0
|
||||
dex:pageScroll(1)
|
||||
eq(dex.scroll, 7, "Right pages the scroll offset down seven rows")
|
||||
eq(dex.index - dex.scroll, 3, "the cursor keeps the screen row it was on")
|
||||
dex:pageScroll(1)
|
||||
eq(dex.scroll, 13, "the second page clamps to wDexMaxSeenMon - 7")
|
||||
eq(dex.index - dex.scroll, 3, "the clamped page still keeps the cursor row")
|
||||
dex:pageScroll(-1)
|
||||
eq(dex.scroll, 6, "Left pages back seven rows")
|
||||
eq(dex.index - dex.scroll, 3, "Left keeps the cursor row too")
|
||||
dex:pageScroll(-1)
|
||||
eq(dex.scroll, 0, "Left stops at the top of the list")
|
||||
eq(dex.index, 3, "the cursor is back where it started")
|
||||
|
||||
-- the witness for the bug: a whole-page jump used to move the cursor to the
|
||||
-- bottom row, which is what a page jump on any other list does
|
||||
check(dex.index - dex.scroll ~= 7,
|
||||
"the cursor did not slide to the last screen row (the ListMenu bug)")
|
||||
|
||||
-- ------------------------------------------------------------- the screen
|
||||
dex.index, dex.scroll = 1, 0
|
||||
calls = {}
|
||||
dex:draw()
|
||||
check(hasText("CONTENTS", 8, 8), "CONTENTS at hlcoord 1,1, not a POKéDEX title")
|
||||
check(hasText("SEEN", 128, 16), "SEEN label at hlcoord 16,2")
|
||||
check(hasText("20", 136, 24), "the seen count right-aligned in its 3-tile field")
|
||||
check(hasText("OWN", 128, 40), "OWN label at hlcoord 16,5")
|
||||
check(hasText("3", 144, 48), "the owned count right-aligned under it")
|
||||
check(not hasText("SEEN 20 OWN 3", 8, 136),
|
||||
"the counts are not a bottom footer line any more")
|
||||
check(hasText("DATA", 128, 80) and hasText("CRY", 128, 96)
|
||||
and hasText("AREA", 128, 112) and hasText("QUIT", 128, 128),
|
||||
"DATA/CRY/AREA/QUIT are permanent furniture at hlcoord 16,10")
|
||||
|
||||
check(hasText("001", 8, 16), "row 1's number sits one row above its name")
|
||||
check(hasText("MON001", 32, 24), "row 1's name is at column 4")
|
||||
check(hasCode(Theme.cursor, 0, 24), "the cursor is at column 0 on row 3")
|
||||
check(hasText("007", 8, 112) and hasText("MON007", 32, 120),
|
||||
"seven rows, two tiles apart, end on row 15")
|
||||
check(not hasText("MON008", 32, 136), "there is no eighth row")
|
||||
|
||||
-- the hollow ▷ pokered leaves under the side menu (PlaceUnfilledArrowMenuCursor)
|
||||
dex.hollowIndex = 1
|
||||
calls = {}
|
||||
dex:draw()
|
||||
check(hasCode(Theme.cursorHollow, 0, 24),
|
||||
"the chosen row keeps a hollow cursor while the side menu is open")
|
||||
|
||||
-- ------------------------------------------------------ unseen dashed rows
|
||||
local partial = newGame()
|
||||
partial.save.pokedex.seen.DEXMON_005 = nil
|
||||
local gapped = PokedexMenu.new(partial, {})
|
||||
eq(gapped.items[5].name, "----------",
|
||||
"an unseen number inside the range prints the dashed line")
|
||||
check(gapped.items[5].value == nil, "and cannot be chosen")
|
||||
|
||||
T.finish("pokedex contents bug 1829")
|
||||
@@ -23,7 +23,7 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- Lazily-required inside the use branches, so seeding package.loaded before
|
||||
@@ -34,8 +34,15 @@ package.loaded["src.core.Sound"] = {
|
||||
}
|
||||
-- Real TextBoxes want a Font atlas. This flow only cares that a message
|
||||
-- opened, what it says, and what its onDone does.
|
||||
local jingles = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
soundOpts = function(_, sound, opts)
|
||||
jingles[#jingles + 1] = sound
|
||||
opts = opts or {}
|
||||
opts.auto = { sound = sound, wait = true }
|
||||
return opts
|
||||
end,
|
||||
}
|
||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
|
||||
-- the stub; Screens caches its factory per id and must be told to forget.
|
||||
@@ -133,6 +140,7 @@ end
|
||||
-- The bug: three candies in the bag, use one in the field.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
jingles = {}
|
||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||
@@ -156,6 +164,10 @@ do
|
||||
check(box.text:find("level 6", 1, true) ~= nil,
|
||||
"and it names the new level: " .. tostring(box.text))
|
||||
end
|
||||
-- RareCandyText: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (engine/menus/party_menu.asm:289-293)
|
||||
same(jingles, { "Get_Item1" },
|
||||
"the level-up line carries sound_get_item_1 and nothing else")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -221,6 +221,11 @@ do
|
||||
local seen = {}
|
||||
package.loaded["src.save_convert.SaveConvert"] = {
|
||||
SAVE_SIZE = 32768,
|
||||
-- importToSlot asks this before it measures the bytes, so a save for a
|
||||
-- game with no codec is refused as that rather than as a bad checksum.
|
||||
-- The double has to answer it; "yes" is what keeps this case about the
|
||||
-- cache-name contract below and nothing else.
|
||||
importSupported = function() return true end,
|
||||
importSav = function(_, version, gameVersion)
|
||||
seen.import = { version = version, gameVersion = gameVersion }
|
||||
return nil, "stub"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- The slot machine's menus sit where the original draws them (#1811).
|
||||
--
|
||||
-- The bet menu is `hlcoord 14,11 / b=5 / c=4 / TextBoxBorder`
|
||||
-- (engine/slots/slot_machine.asm:83-86), i.e. a 6x7 box whose bottom edge is
|
||||
-- the last row of the screen, with the multipliers at hlcoord 16,12 and the
|
||||
-- cursor at wTopMenuItemX 15 (asm:77-78, :87). "One more go?" is a
|
||||
-- TWO_OPTION_MENU at hlcoord 14,12 (asm:136-138) over the YES_NO_MENU body
|
||||
-- (width 4, height 3 -> a 6x5 box; data/yes_no_menu_strings.asm:10).
|
||||
-- The "Want to play?" prompt is not this screen's at all: PromptUserToPlaySlots
|
||||
-- asks it before LoadSlotMachineTiles (asm:9-23), so the screen opens on the
|
||||
-- bet menu.
|
||||
-- luajit tests/engine/slot_machine_boxes_bug1811.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")
|
||||
|
||||
-- Font wants a real atlas and this suite is about which box lands on which
|
||||
-- row, so it records the calls instead (tests/engine/bag_item_box_bug1521.lua's
|
||||
-- shape)
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
|
||||
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
|
||||
width = function(text) return #tostring(text) * 8 end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
package.loaded["src.ui.SlotMachine"] = nil
|
||||
local SlotMachine = require("src.ui.SlotMachine")
|
||||
|
||||
local game = {
|
||||
data = { field = { slotWheels = {} }, text = {} },
|
||||
save = { coins = 100 },
|
||||
}
|
||||
|
||||
local function record(fn)
|
||||
calls = {}
|
||||
fn()
|
||||
return calls
|
||||
end
|
||||
|
||||
local function box(list, tx, ty)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "box" and c[2] == tx and c[3] == ty then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local function drawn(list, text)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "draw" and c[2] == text then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local function cursor(list)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "code" and c[2] == 0xED then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local slots = SlotMachine.new(game, false)
|
||||
eq(slots.stage, "bet", "the screen opens on the bet menu, not a prompt")
|
||||
|
||||
local bet = record(function() slots:drawBottom() end)
|
||||
local betBox = box(bet, 14, 11)
|
||||
check(betBox ~= nil, "the bet menu box starts at (14,11)")
|
||||
eq(betBox and betBox[4], 6, "it is 6 tiles wide (c = 4)")
|
||||
eq(betBox and betBox[5], 7, "and 7 tall (b = 5), down to the last row")
|
||||
eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bottom")
|
||||
local x3 = drawn(bet, "×3")
|
||||
eq(x3 and x3[3], 16 * 8, "the multipliers print at column 16")
|
||||
eq(x3 and x3[4], 12 * 8, "on row 12")
|
||||
local betCursor = cursor(bet)
|
||||
eq(betCursor and betCursor[3], 15 * 8, "the cursor sits at column 15")
|
||||
eq(betCursor and betCursor[4], 12 * 8, "on the ×3 row by default")
|
||||
|
||||
slots.stage = "onemore"
|
||||
slots.yesno = 1
|
||||
local more = record(function() slots:drawBottom() end)
|
||||
local moreBox = box(more, 14, 12)
|
||||
check(moreBox ~= nil, "the One more go? menu box starts at (14,12)")
|
||||
eq(moreBox and moreBox[4], 6, "it is 6 tiles wide")
|
||||
eq(moreBox and moreBox[5], 5, "and 5 tall (YES_NO_MENU 4x3)")
|
||||
check(box(more, 13, 12) == nil, "and not one column left of that")
|
||||
local yes, no = drawn(more, "YES"), drawn(more, "NO")
|
||||
eq(yes and yes[3], 16 * 8, "YES prints at column 16")
|
||||
eq(yes and yes[4], 13 * 8, "on row 13")
|
||||
eq(no and no[4], 14 * 8, "NO one row below it")
|
||||
local moreCursor = cursor(more)
|
||||
eq(moreCursor and moreCursor[3], 15 * 8, "its cursor sits at column 15")
|
||||
eq(moreCursor and moreCursor[4], 13 * 8, "starting on YES")
|
||||
|
||||
slots.yesno = 2
|
||||
local onNo = record(function() slots:drawBottom() end)
|
||||
eq(cursor(onNo) and cursor(onNo)[4], 14 * 8, "and moves to the NO row")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,52 @@
|
||||
-- The START cursor is unsaved WRAM (ram/wram.asm:238-242) and lies outside
|
||||
-- sGameData (ram/sram.asm:17-21), so it must not ride the save file (#1821).
|
||||
-- luajit tests/run_engine.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StartMenu = require("src.ui.StartMenu")
|
||||
|
||||
local function stubGame(save)
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return {
|
||||
data = Data, save = save, stack = stack,
|
||||
input = { wasPressed = function() return false end,
|
||||
isDown = function() return false end },
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
local game = stubGame(save)
|
||||
local menu = StartMenu.new(game)
|
||||
menu.index = math.min(3, #menu.items)
|
||||
menu:update(1 / 60)
|
||||
T.eq(game.startMenuIndex, menu.index, "the cursor slot lands on the game")
|
||||
T.check(save.startMenuIndex == nil, "and never on the save table")
|
||||
|
||||
local reopened = StartMenu.new(stubGame(save))
|
||||
T.eq(reopened.index, 1, "a fresh session opens on the top row")
|
||||
|
||||
local same = StartMenu.new(game)
|
||||
T.eq(same.index, menu.index, "the same session reopens where it was")
|
||||
end
|
||||
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.startMenuIndex = 5
|
||||
local encoded = SaveData.encodeForTest and SaveData.encodeForTest(save) or nil
|
||||
T.check(encoded == nil or encoded:find("startMenuIndex") == nil,
|
||||
"no serializer writes the cursor back out")
|
||||
local game = stubGame(save)
|
||||
local menu = StartMenu.new(game)
|
||||
T.eq(menu.index, 1,
|
||||
"a legacy save file's stored slot no longer reopens the menu on it")
|
||||
end
|
||||
|
||||
T.finish("START menu cursor is session state (#1821)")
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The forget-a-move text is not one uninterrupted string: pokered's
|
||||
-- OneTwoAndText holds the box with text_pause, plays SFX_SWAP, prints
|
||||
-- " Poof!" and holds it again before the paragraph break
|
||||
-- (engine/pokemon/learn_move.asm:208-222, home/text.asm:492-504). The port
|
||||
-- typed all of it straight through in silence (#1818). TextBox.PAUSE now
|
||||
-- carries those waits inside the string, with opts.pauseSounds naming the
|
||||
-- sfx each one fires. ROM-free.
|
||||
-- luajit tests/engine/textbox_pause_bug1818.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 TextBox = require("src.render.TextBox")
|
||||
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local played = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
played[#played + 1] = name
|
||||
return nil
|
||||
end
|
||||
|
||||
local function newGame()
|
||||
local game = {
|
||||
save = { player = {}, options = { textSpeed = "FAST" } },
|
||||
data = { text = {}, moves = { MEGA_PUNCH = { name = "MEGA PUNCH" } },
|
||||
pokemon = { NIDORINO = { name = "NIDORINO" } } },
|
||||
}
|
||||
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 game = newGame()
|
||||
local box = TextBox.new(game, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
|
||||
nil, { pauseSounds = { "Swap" } })
|
||||
|
||||
eq(box.pages[1][1], "1, 2 and... Poof!",
|
||||
"the marker is stripped out of the line the font ever sees")
|
||||
check(box.pauseAt and box.pauseAt[1] and box.pauseAt[1][1],
|
||||
"the pause is re-anchored onto page 1, line 1")
|
||||
eq(box.pauseAt[1][1][11], 1, "it sits on the 11th glyph, the last of \"1, 2 and...\"")
|
||||
|
||||
-- type up to the marker
|
||||
local frames = 0
|
||||
while not box.pauseFrames and frames < 600 do
|
||||
box:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
check(box.pauseFrames, "typing stops at the marker")
|
||||
eq(box.charIndex, 11, "it stops with \"1, 2 and...\" printed and nothing after it")
|
||||
eq(#played, 0, "SFX_SWAP has not sounded yet -- the wait comes first")
|
||||
|
||||
local frozen = box.charIndex
|
||||
for _ = 1, 31 do
|
||||
box:update(1 / 60)
|
||||
if #played > 0 then break end
|
||||
eq(box.charIndex, frozen, "nothing types while the pause runs")
|
||||
end
|
||||
eq(played[1], "Swap", "the wait ends with SFX_SWAP (learn_move.asm:210-213)")
|
||||
|
||||
for _ = 1, 600 do
|
||||
if box.done then break end
|
||||
box:update(1 / 60)
|
||||
end
|
||||
check(box.done, "\" Poof!\" types out after the sound")
|
||||
eq(box.charIndex, 17, "the whole line printed, marker included in nothing")
|
||||
|
||||
-- a held A/B skips the wait: TextCommand_PAUSE reads hJoyHeld
|
||||
local held = newGame()
|
||||
held.input.queue = { a = true }
|
||||
local box2 = TextBox.new(held, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
|
||||
nil, { pauseSounds = { "Swap" } })
|
||||
local before = #played
|
||||
for _ = 1, 40 do
|
||||
if box2.done then break end
|
||||
box2:update(1 / 60)
|
||||
end
|
||||
check(box2.done, "with A held the box runs straight through the pause")
|
||||
check(#played > before, "the sound still plays on the skipped wait")
|
||||
|
||||
-- and the real caller wires both waits with the swap on the first
|
||||
local mlm = MoveLearnMenu.new(game, { species = "NIDORINO", level = 20,
|
||||
moves = {} }, "MEGA_PUNCH")
|
||||
mlm.forgot = "HORN ATTACK"
|
||||
game.stack:push(mlm)
|
||||
mlm:finish(true)
|
||||
local learned = game.stack:top()
|
||||
check(learned.pauseAt, "the learned-move box carries text_pause waits")
|
||||
eq(learned.pauseSounds[1], "Swap", "the first wait carries SFX_SWAP")
|
||||
local count = 0
|
||||
for _, lines in pairs(learned.pauseAt) do
|
||||
for _, chars in pairs(lines) do
|
||||
for _ in pairs(chars) do count = count + 1 end
|
||||
end
|
||||
end
|
||||
eq(count, 2, "both of learn_move.asm's text_pause commands survive")
|
||||
|
||||
Sound.play = realPlay
|
||||
T.finish()
|
||||
@@ -133,16 +133,18 @@ check(ow.player.spinDrop ~= true and ow.player.spinning == false,
|
||||
check(not playerHidden(ow), "player drawable again after the dig landing")
|
||||
|
||||
-- ------------------------------------------------------------------ fly
|
||||
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
|
||||
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
|
||||
-- the StopMusic fade hold (28) then flap (24) + path1 (36) + hold (40) +
|
||||
-- path2 (33) = 161 frames of flyAnim, then the fade, then the bird swoops
|
||||
-- in (flyArrive). Same invariant.
|
||||
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
|
||||
ow = newOW()
|
||||
ow:flyTo("FIX_ROUTE")
|
||||
st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
|
||||
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
|
||||
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
|
||||
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
|
||||
-- fade hold (4*7) + flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3)
|
||||
-- = 161 frames; the warp fires on frame 161's update, so the fade is on top
|
||||
-- from loop frame 162 (player_animations.asm:123 StopMusic) #1840
|
||||
eq(st.warpFrame, 162, "fly fade begins right after the bird''s exit path")
|
||||
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no fly fade frame leaves the player standing bare (#916)")
|
||||
|
||||
@@ -645,6 +645,16 @@ save = newSave({ eggWith(0) })
|
||||
hatched = Breeding.hatch(DATA, save, 1, "SPROUT")
|
||||
eq(hatched.nickname, "SPROUT", "a nickname taken from the naming screen sticks")
|
||||
|
||||
-- HatchEggs writes wPlayerID / wPlayerName over whatever the egg carried, so
|
||||
-- the ODD_EGG's "ODD" and its table id do not survive the hatch.
|
||||
-- engine/pokemon/breeding.asm:299-309
|
||||
save = newSave({ eggWith(0) })
|
||||
save.party[1].ot = "ODD"
|
||||
save.party[1].otId = 2048
|
||||
hatched = Breeding.hatch(DATA, save, 1)
|
||||
eq(hatched.ot, "GOLD", "the hatchling's OT becomes the player")
|
||||
eq(hatched.otId, 1234, "and MON_OT_ID becomes wPlayerID")
|
||||
|
||||
eq(#Breeding.readyToHatch(newSave({ eggWith(0), eggWith(2), eggWith(0) })), 2,
|
||||
"readyToHatch names every spent counter")
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- The #DEX screen (src/ui/gen2/PokedexMenu.lua) reads its KIND label and
|
||||
-- description pages from data.gen2Pokedex.entries, a table loaded straight
|
||||
-- from disk before mods:load runs -- a separate table from data.pokemon,
|
||||
-- the `pokemon` registry's own merge target
|
||||
-- (mod.content.pokemon:patch(id, { dexEntry = ... })). Without a projection
|
||||
-- step, a translation mod's #DEX text validates against the registry and
|
||||
-- never reaches the screen. src/core/gen2/PokedexText.lua is that
|
||||
-- projection, called once after the merge (src/core/Game2.lua). ROM-free:
|
||||
-- luajit tests/gen2_pokedex_text_registry_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 pokedex text registry")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local PokedexText = require("src.core.gen2.PokedexText")
|
||||
|
||||
-- A patched species (CYNDAQUIL, kind/text/text2 all overridden), one left
|
||||
-- alone (TOTODILE, no dexEntry at all in data.pokemon -- a mod that never
|
||||
-- touched it), and one gen2Pokedex has no matching data.pokemon row for
|
||||
-- (UNOWN, e.g. a form gen2Pokedex tracks that the pokemon registry does not).
|
||||
do
|
||||
local data = {
|
||||
pokemon = {
|
||||
CYNDAQUIL = { name = "CYNDAQUIL", dexEntry = {
|
||||
kind = "SOURIS DE FEU", text = "Page un traduite.",
|
||||
text2 = "Page deux traduite.",
|
||||
} },
|
||||
TOTODILE = { name = "TOTODILE" },
|
||||
},
|
||||
gen2Pokedex = { entries = {
|
||||
CYNDAQUIL = { id = "CYNDAQUIL", dex = 155, kind = "FIRE MOUSE",
|
||||
height = 108, weight = 170,
|
||||
text = "It is timid, and always curls itself up in a ball.",
|
||||
text2 = "If attacked, it flares up its back for protection." },
|
||||
TOTODILE = { id = "TOTODILE", dex = 158, kind = "BIG JAW",
|
||||
height = 200, weight = 200, text = "English page one.",
|
||||
text2 = "English page two." },
|
||||
UNOWN = { id = "UNOWN", dex = 201, kind = "SYMBOL",
|
||||
height = 50, weight = 50, text = "English only." },
|
||||
} },
|
||||
}
|
||||
|
||||
local touched = PokedexText.apply(data)
|
||||
eq(touched, 1, "only the species a mod actually patched are touched")
|
||||
|
||||
local cyndaquil = data.gen2Pokedex.entries.CYNDAQUIL
|
||||
eq(cyndaquil.kind, "SOURIS DE FEU", "kind reaches the #DEX entry")
|
||||
eq(cyndaquil.text, "Page un traduite.", "page one reaches the #DEX entry")
|
||||
eq(cyndaquil.text2, "Page deux traduite.", "page two reaches the #DEX entry")
|
||||
eq(cyndaquil.dex, 155, "dex number is untouched -- it is not translatable text")
|
||||
eq(cyndaquil.height, 108, "height is untouched -- game state, not prose")
|
||||
eq(cyndaquil.weight, 170, "weight is untouched -- game state, not prose")
|
||||
|
||||
local totodile = data.gen2Pokedex.entries.TOTODILE
|
||||
eq(totodile.kind, "BIG JAW", "a species with no dexEntry patch stays in English")
|
||||
eq(totodile.text, "English page one.", "unpatched page one is untouched")
|
||||
|
||||
local unown = data.gen2Pokedex.entries.UNOWN
|
||||
eq(unown.kind, "SYMBOL",
|
||||
"a #DEX entry with no data.pokemon counterpart at all is left alone")
|
||||
end
|
||||
|
||||
-- A patch that only sets `kind` (species_kinds without species_dex_text
|
||||
-- shipped, or vice versa) must not blank out the fields it did not touch.
|
||||
do
|
||||
local data = {
|
||||
pokemon = { ABRA = { name = "ABRA", dexEntry = { kind = "PSY" } } },
|
||||
gen2Pokedex = { entries = {
|
||||
ABRA = { id = "ABRA", dex = 63, kind = "PSI",
|
||||
text = "English text.", text2 = "English text two." },
|
||||
} },
|
||||
}
|
||||
PokedexText.apply(data)
|
||||
local abra = data.gen2Pokedex.entries.ABRA
|
||||
eq(abra.kind, "PSY", "kind alone is applied")
|
||||
eq(abra.text, "English text.", "text is left alone when the patch omits it")
|
||||
eq(abra.text2, "English text two.", "text2 is left alone when the patch omits it")
|
||||
end
|
||||
|
||||
-- Missing tables at every level answer 0 rather than raising -- a Gen 1 boot,
|
||||
-- or a Gold boot with no mods loaded, never calls this with a shaped table.
|
||||
do
|
||||
eq(PokedexText.apply(nil), 0, "nil data does not raise")
|
||||
eq(PokedexText.apply({}), 0, "empty data does not raise")
|
||||
eq(PokedexText.apply({ gen2Pokedex = { entries = {} } }), 0,
|
||||
"no data.pokemon at all does not raise")
|
||||
end
|
||||
|
||||
check(true, "PokedexText.apply covers the projection this fix adds")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,96 @@
|
||||
-- A Gen 2 cart save must be refused as a Gen 2 cart save, not as a corrupt
|
||||
-- Gen 1 one (#1832).
|
||||
-- luajit tests/gen2_save_import_message_test.lua
|
||||
-- Also dofile'd by tests/run_tests.lua.
|
||||
--
|
||||
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
|
||||
-- SaveConvert.mainChecksumValid, which is pokered's main-data checksum. Gen 2
|
||||
-- carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save carries an
|
||||
-- RTC footer and is 32786 bytes: it misses the size test, is then measured
|
||||
-- against a checksum rule written for a different generation, and the launcher
|
||||
-- tells the player their save is corrupt. It is not -- there is simply no Gen
|
||||
-- 2 codec yet, which is a different sentence and an actionable one.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("gen2 save import message")
|
||||
local check = S.check
|
||||
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveFileIO = require("src.import.SaveFileIO")
|
||||
|
||||
-- The size a real Gen 2 cart save actually is: 32768 bytes of SRAM plus the
|
||||
-- 18-byte RTC footer an MBC3+TIMER cart writes.
|
||||
local GEN2_CART_SAVE_SIZE = 32786
|
||||
|
||||
local function blob(n) return string.rep("\0", n) end
|
||||
|
||||
-- readSource only takes a raw string when it is EXACTLY 32768 bytes; anything
|
||||
-- else is treated as a picker path (its own comment says so). A real Gen 2
|
||||
-- cart save is 32786, so it can only ever reach importToSlot as a FILE -- which
|
||||
-- is exactly how the player in #1832 supplied theirs. Write one and hand over
|
||||
-- the path, so this exercises the route the report came from.
|
||||
local function savFile(n)
|
||||
local path = os.tmpname()
|
||||
local f = assert(io.open(path, "wb"))
|
||||
f:write(blob(n))
|
||||
f:close()
|
||||
return path
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- The report: a real Gen 2 save is not "checksum invalid"
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), version, true)
|
||||
check(ok == false, version .. ": a Gen 2 cart save is still refused")
|
||||
check(type(err) == "string" and err:find("Gen 2 cart save", 1, true) ~= nil,
|
||||
version .. ": refused AS a Gen 2 save -- got: " .. tostring(err))
|
||||
check(type(err) == "string" and err:find("checksum", 1, true) == nil,
|
||||
version .. ": never blamed on a checksum it was never measured by -- got: "
|
||||
.. tostring(err))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- The predicate both callers share
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
for _, version in ipairs({ "red", "blue", "yellow" }) do
|
||||
check(SaveConvert.importSupported(version) == true,
|
||||
version .. ": Gen 1 import is unaffected")
|
||||
check(SaveConvert.exportSupported(version) == true,
|
||||
version .. ": Gen 1 export is unaffected")
|
||||
end
|
||||
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local impOk, impWhy = SaveConvert.importSupported(version)
|
||||
local expOk, expWhy = SaveConvert.exportSupported(version)
|
||||
check(impOk == false and expOk == false, version .. ": both directions say no")
|
||||
-- One sentence per direction, wherever it is asked from: the early gate in
|
||||
-- SaveFileIO and the late one inside importSav must not describe the same
|
||||
-- game two different ways.
|
||||
local _, lateWhy = SaveConvert.importSav(blob(32768), version, version)
|
||||
check(impWhy == lateWhy,
|
||||
version .. ": the early gate and importSav answer identically")
|
||||
check(expWhy:find("exporting", 1, true) ~= nil,
|
||||
version .. ": the export sentence is about exporting")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Gen 1 keeps its own diagnosis
|
||||
-- ------------------------------------------------------------------
|
||||
--
|
||||
-- A Gen 1 save that really is the wrong size AND fails pokered's checksum must
|
||||
-- still say so: this fix moves the generation check in front of that test, it
|
||||
-- does not remove it.
|
||||
|
||||
do
|
||||
local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), "red", true)
|
||||
check(ok == false, "red: a corrupt oversize save is still refused")
|
||||
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
|
||||
"red: still diagnosed by pokered's checksum -- got: " .. tostring(err))
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -526,6 +526,93 @@ for _, id in ipairs(Screens.GEN2_IDS) do
|
||||
end
|
||||
check("Gen2UnownPuzzle is a screen id", registered, true)
|
||||
|
||||
-- ================================================= Crystal's flag skew (#1834)
|
||||
--
|
||||
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts the
|
||||
-- unlock flags to 43-46 (:57-60); pokegold keeps 42-45 (:56-59).
|
||||
do
|
||||
local World = require("src.world.gen2.World")
|
||||
local crystalOrder = {}
|
||||
for _, set in ipairs(Unown.UNLOCK_SETS) do
|
||||
crystalOrder[set.flag + 2] = set.name
|
||||
end
|
||||
local function stubWorld(engineFlags, order)
|
||||
return setmetatable({
|
||||
constants = { engineFlagOrder = order },
|
||||
game = { save = { engineFlags = engineFlags } },
|
||||
}, { __index = World })
|
||||
end
|
||||
|
||||
-- what a Crystal save's raw flags used to feed Unown directly: L-Z only
|
||||
local rawCrystal = { [43] = true, [44] = true, [45] = true, [46] = true }
|
||||
local leaked = Unown.unlockedLetters(rawCrystal)
|
||||
check("raw Crystal flags leak only 15 letters", #leaked, 15)
|
||||
check("and the first survivor is L", leaked[1], 12)
|
||||
|
||||
local view = stubWorld(rawCrystal, crystalOrder):unownUnlockFlags()
|
||||
local resolved = Unown.unlockedLetters(view)
|
||||
check("the resolved view unlocks all 26", #resolved, 26)
|
||||
check("A included", resolved[1], 1)
|
||||
|
||||
-- the same distribution through wildDVs itself
|
||||
math.randomseed(1)
|
||||
local function roll()
|
||||
return { attack = math.random(0, 15), defense = math.random(0, 15),
|
||||
speed = math.random(0, 15), special = math.random(0, 15) }
|
||||
end
|
||||
local seenOld, seenNew = {}, {}
|
||||
for _ = 1, 4000 do
|
||||
seenOld[Unown.letterFromDVs(Unown.wildDVs(rawCrystal, roll))] = true
|
||||
seenNew[Unown.letterFromDVs(Unown.wildDVs(view, roll))] = true
|
||||
end
|
||||
local countOld, countNew = 0, 0
|
||||
for _ in pairs(seenOld) do countOld = countOld + 1 end
|
||||
for _ in pairs(seenNew) do countNew = countNew + 1 end
|
||||
check("raw flags roll only 15 letters", countOld, 15)
|
||||
check("raw flags never roll A", seenOld[1], nil)
|
||||
check("the resolved view rolls all 26", countNew, 26)
|
||||
|
||||
-- one chamber: Crystal's setflag 43 is Kabuto's A-K, not L-R
|
||||
local kabuto = Unown.unlockedLetters(
|
||||
stubWorld({ [43] = true }, crystalOrder):unownUnlockFlags())
|
||||
check("Crystal flag 43 is A-K", #kabuto, 11)
|
||||
check("starting at A", kabuto[1], 1)
|
||||
|
||||
-- Crystal's 42 is ENGINE_EARTHBADGE and must not unlock a chamber
|
||||
check("Crystal flag 42 unlocks nothing", Unown.anyUnlocked(
|
||||
stubWorld({ [42] = true }, crystalOrder):unownUnlockFlags()), false)
|
||||
|
||||
-- a Gold cache has no engineFlagOrder and keeps its own ids
|
||||
local gold = Unown.unlockedLetters(
|
||||
stubWorld({ [42] = true }, nil):unownUnlockFlags())
|
||||
check("Gold flag 42 stays A-K", #gold, 11)
|
||||
end
|
||||
|
||||
-- ============================================= the letters table's keys (#1834)
|
||||
--
|
||||
-- RomExtractorGen2 keys `letters` by "A".."Z"; monLetter answers a number, so
|
||||
-- the anim lookup must convert or every letter animates as A.
|
||||
do
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
local animA, animI = { sheet = "sheet-a" }, { sheet = "sheet-i" }
|
||||
data.pokemon.UNOWN.anim = animA
|
||||
data.pokemon.UNOWN.letters.I.anim = animI
|
||||
local screen = { pokemon = data.pokemon }
|
||||
check("animData picks the mon's own letter",
|
||||
BattleState.animData(screen, unown), animI)
|
||||
local plain = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(2) })
|
||||
check("a letter with no anim row falls back to the species'",
|
||||
BattleState.animData(screen, plain), animA)
|
||||
-- StatsScreen_PlaceFrontpic reads the same letter row
|
||||
-- (../pokecrystal/engine/pokemon/stats_screen.asm:889-901)
|
||||
local asked
|
||||
local summary = { mon = unown, pokemon = data.pokemon,
|
||||
picImage = function(_, sheet) asked = sheet return nil end }
|
||||
SummaryMenu.startPicAnim(summary)
|
||||
check("the summary menu reads the same letter row", asked, "sheet-i")
|
||||
end
|
||||
|
||||
print(("gen2 unown: %d checks, %d failures"):format(checks, failures))
|
||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
||||
-- here would take the whole tier down and silently skip every suite after it.
|
||||
|
||||
@@ -922,8 +922,12 @@ check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared")
|
||||
runFrames(poolWorld, 1)
|
||||
eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText")
|
||||
advanceText(poolWorld)
|
||||
-- engine/events/overworld.asm:1142-1164
|
||||
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x07,
|
||||
"the whirlpool block is still on screen while the sfx plays (#1862)")
|
||||
runFrames(poolWorld, 4)
|
||||
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36,
|
||||
"DisappearWhirlpool swaps block $07 for $36")
|
||||
"DisappearWhirlpool swaps block $07 for $36 once the sfx ends")
|
||||
local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER)
|
||||
check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok,
|
||||
"a whirlpool collision over the wrong block is refused")
|
||||
|
||||
@@ -558,8 +558,9 @@ local pm = PartyMenu.new(pgame)
|
||||
pm.game = pgame
|
||||
pgame.stack:push(pm)
|
||||
press(pm, "a")
|
||||
check(pm.submenu and #pm.subItems == 2
|
||||
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
|
||||
check(pm.submenu and #pm.subItems == 3
|
||||
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH"
|
||||
and pm.subItems[3].label == "CANCEL",
|
||||
"vanilla party submenu unchanged with no hooks")
|
||||
pm.submenu = nil
|
||||
|
||||
@@ -570,9 +571,9 @@ hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
|
||||
return nextFn(game, items, mon, ctx)
|
||||
end, 0, "fixture")
|
||||
press(pm, "a")
|
||||
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
|
||||
check(#pm.subItems == 4 and pm.subItems[4].label == "QUESTS",
|
||||
"hook appends a party submenu entry")
|
||||
pm.subIndex = 3
|
||||
pm.subIndex = 4
|
||||
press(pm, "a")
|
||||
check(ranWith == pgame.save.party[1],
|
||||
"an injected entry's onSelect runs with the focused mon")
|
||||
@@ -581,7 +582,7 @@ hooks:removeOwner("fixture")
|
||||
|
||||
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
|
||||
press(pm, "a")
|
||||
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
|
||||
check(#pm.subItems == 3, "a non-table submenu result keeps the vanilla list")
|
||||
hooks:removeOwner("bad")
|
||||
pm.submenu = nil
|
||||
|
||||
|
||||
+5
-2
@@ -44,11 +44,14 @@ end
|
||||
TileRenderer.setSpinning(false)
|
||||
check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin")
|
||||
|
||||
-- one toggle per 16-frame walked tile: wSimulatedJoypadStatesIndex drops
|
||||
-- once per JoypadOverworld call (home/overworld.asm:1844-1846) and
|
||||
-- spinners.asm:18-22 reads its bit 0 (#1831)
|
||||
TileRenderer.setSpinning(true)
|
||||
local a = TileRenderer.spinBlurActive()
|
||||
for i = 1, 8 do TileRenderer.tick() end
|
||||
for i = 1, 16 do TileRenderer.tick() end
|
||||
local b = TileRenderer.spinBlurActive()
|
||||
check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning")
|
||||
check(a ~= b, "arrow blur frame toggles every ~16 ticks while spinning")
|
||||
|
||||
TileRenderer.setSpinning(false)
|
||||
check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends")
|
||||
|
||||
@@ -303,7 +303,9 @@ Game.save.forcedBike = true
|
||||
ow = pushOW("ROUTE_17", 4, 10, "down")
|
||||
ow:flyTo("PALLET_TOWN")
|
||||
eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE")
|
||||
ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo
|
||||
-- undo flyTo, flyFade included: it re-arms flyAnim when it drains
|
||||
ow.flyAnim, ow.flyDest, ow.flyFade = nil, nil, nil
|
||||
ow.player.inputLocked = false
|
||||
Game.save.forcedBike = true
|
||||
ow:warpToHealPoint()
|
||||
eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Parity test: an AI trainer switch clears the exp participant flags.
|
||||
--
|
||||
-- pokered's AI switch path calls EnemySendOut, which zeroes
|
||||
-- wPartyGainExpFlags and re-flags only wPlayerMonNumber before falling
|
||||
-- into EnemySendOutFirstMon (engine/battle/core.asm:1276-1292). The port
|
||||
-- kept the old participants, so a mon that had fought the withdrawn foe
|
||||
-- still halved the exp for the newly sent-out one (#1826).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ai_switch_exp.lua`.
|
||||
-- Also picked up by tests/run_tests.lua's parity_* glob.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local S = require("tests.harness").suite("parity ai switch exp")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function freshGame()
|
||||
return {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "CHARMANDER", 50) },
|
||||
player = { name = "RED", id = 1234 },
|
||||
inventory = {},
|
||||
options = { battleStyle = "set" },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
-- Both party mons fought the first foe; the AI then withdraws it.
|
||||
local function battleAfterAiSwitch()
|
||||
local Game = freshGame()
|
||||
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
|
||||
check(#b.enemyParty >= 2, "the trainer has a reserve to switch to")
|
||||
b.participants = { [Game.save.party[1]] = true, [Game.save.party[2]] = true }
|
||||
b:executeAction(b.enemy, b.player, { special = "aiSwitch", index = 2 })
|
||||
return Game, b
|
||||
end
|
||||
|
||||
do
|
||||
local Game, b = battleAfterAiSwitch()
|
||||
eq(b.participants[Game.save.party[1]], true,
|
||||
"the mon on the field is the one participant after the switch")
|
||||
eq(b.participants[Game.save.party[2]], nil,
|
||||
"the benched mon lost its gain-exp flag (core.asm:1276-1289)")
|
||||
end
|
||||
|
||||
-- The divisor follows: the on-field mon takes the whole share, and the
|
||||
-- benched one earns nothing off the new foe's KO.
|
||||
do
|
||||
local Game, b = battleAfterAiSwitch()
|
||||
b.enemy.mon.hp = 0
|
||||
local before1 = Game.save.party[1].exp
|
||||
local before2 = Game.save.party[2].exp
|
||||
b:awardExp()
|
||||
local gainedSwitched = Game.save.party[1].exp - before1
|
||||
check(gainedSwitched > 0, "the mon on the field is paid for the KO")
|
||||
eq(Game.save.party[2].exp - before2, 0, "the benched mon is paid nothing")
|
||||
|
||||
-- Reference: the same KO with a single flagged participant from the start.
|
||||
local Game2 = freshGame()
|
||||
local b2 = BattleState.newTrainer(Game2, "OPP_AGATHA", 1)
|
||||
b2:executeAction(b2.enemy, b2.player, { special = "aiSwitch", index = 2 })
|
||||
b2.participants = { [Game2.save.party[1]] = true }
|
||||
b2.enemy.mon.hp = 0
|
||||
local ref = Game2.save.party[1].exp
|
||||
b2:awardExp()
|
||||
eq(gainedSwitched, Game2.save.party[1].exp - ref,
|
||||
"the share is undivided, not halved by the stale participant")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -35,12 +35,12 @@ local check, eq = S.check, S.eq
|
||||
|
||||
-- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy,
|
||||
-- so the lowest roll lands HYPNOSIS (60%) every run
|
||||
local function freshBattle()
|
||||
local function freshBattle(rng)
|
||||
Game.save.options.animations = true
|
||||
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) }
|
||||
local tb = BattleState.newWild(Game, "PIDGEY", 10)
|
||||
tb.queue, tb.nextInsert = {}, 0
|
||||
tb.rng = function(a) return a end
|
||||
tb.rng = rng or function(a) return a end
|
||||
return tb
|
||||
end
|
||||
|
||||
@@ -53,8 +53,8 @@ local function hitRows(tb)
|
||||
return out
|
||||
end
|
||||
|
||||
local function typeOf(moveId, isPlayer)
|
||||
local tb = freshBattle()
|
||||
local function typeOf(moveId, isPlayer, rng)
|
||||
local tb = freshBattle(rng)
|
||||
local user = isPlayer and tb.player or tb.enemy
|
||||
local target = isPlayer and tb.enemy or tb.player
|
||||
tb:performMove(user, target, { id = moveId, pp = 10 }, false)
|
||||
@@ -111,7 +111,10 @@ do
|
||||
eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe")
|
||||
eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6")
|
||||
eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP")
|
||||
eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3")
|
||||
-- StatModifierDownEffect misses on a roll under $40 on the enemy's
|
||||
-- turn -- engine/battle/effects.asm:552
|
||||
eq(typeOf("SAND_ATTACK", false, function() return 0x40 end), 3,
|
||||
"the foe's SAND-ATTACK is type 3")
|
||||
eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6")
|
||||
eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6")
|
||||
eq(typeOf("DISABLE", true), 6, "DISABLE is type 6")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Parity test: the ball-shake pause keeps the resting ball on screen.
|
||||
--
|
||||
-- PlaySubanimation draws the frame block and only then runs
|
||||
-- DoSpecialEffectByAnimationId (engine/battle/animations.asm:623-627), so
|
||||
-- DoBallShakeSpecialEffects' SFX_TINK + DelayFrames 40 (animations.asm:
|
||||
-- 739-747) lands with the ball already drawn. The port paused first and
|
||||
-- showed 40 blank frames per wobble (#1853).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ball_shake_anim.lua`.
|
||||
-- Also picked up by tests/run_tests.lua's parity_* glob.
|
||||
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("parity ball shake anim")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local AnimPlayer = require("src.battle.AnimPlayer")
|
||||
local player = AnimPlayer.new(require("data.generated.battle_anims"))
|
||||
player:start("SHAKE_ANIM", true, { shakes = 3 })
|
||||
|
||||
local tinks = {}
|
||||
for _, e in ipairs(player.events) do
|
||||
if e.effect == "SFX_TINK" then tinks[#tinks + 1] = e.frame end
|
||||
end
|
||||
eq(#tinks, 3, "one SFX_TINK per requested shake")
|
||||
|
||||
-- the step a given frame falls in
|
||||
local function stepAt(frame)
|
||||
local at = 0
|
||||
for _, st in ipairs(player.steps) do
|
||||
if frame >= at and frame < at + st.dur then return st end
|
||||
at = at + st.dur
|
||||
end
|
||||
end
|
||||
|
||||
for i, frame in ipairs(tinks) do
|
||||
local st = stepAt(frame)
|
||||
check(st ~= nil, "shake " .. i .. " has a step at its tink frame")
|
||||
eq(st.dur, 40, "shake " .. i .. " pauses 40 frames on the tink")
|
||||
check(#st.sprites > 0,
|
||||
"shake " .. i .. " shows the resting ball through that pause")
|
||||
end
|
||||
|
||||
check(tinks[1] > 0,
|
||||
"the first tink is not the very first thing the animation does")
|
||||
|
||||
S.finish()
|
||||
@@ -259,7 +259,10 @@ do
|
||||
withdraw(game)
|
||||
check(captured ~= nil, "withdraw opened its box list")
|
||||
if captured then
|
||||
eq(#captured.items, 1, "the list has the one box mon in it")
|
||||
-- engine/menus/players_pc.asm: the list ends in CANCEL (#1847)
|
||||
eq(#captured.items, 2, "the list has the one box mon in it, plus CANCEL")
|
||||
check(captured.items[2] and captured.items[2].cancel,
|
||||
"the last row is the CANCEL terminator")
|
||||
captured.opts.onChoose(captured.items[1],
|
||||
{ index = 1, close = function() end })
|
||||
eq(#save.boxes[1], 0, "the mon left the box")
|
||||
|
||||
@@ -90,7 +90,7 @@ end
|
||||
eq(hooks.onInteract(game, ow, 2, 0), false,
|
||||
"the wall left of the blackboard stays silent")
|
||||
|
||||
-- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again
|
||||
-- walk the loop: intro -> held prompt -> heading menu over it
|
||||
stack = {}
|
||||
hooks.onInteract(game, ow, 3, 0)
|
||||
local intro = stack[#stack]
|
||||
@@ -100,36 +100,52 @@ local prompt = stack[#stack]
|
||||
check(getmetatable(prompt) == TextBox
|
||||
and pages(prompt):find("heading", 1, true) ~= nil,
|
||||
"the intro leads into the which-heading prompt")
|
||||
prompt.onDone()
|
||||
-- #591: LinkCableHelpText2 ends in `text_end`, so .linkHelpLoop leaves it on
|
||||
-- screen and runs HandleMenuInput under it
|
||||
check(prompt.onDone == nil and prompt.stay ~= nil,
|
||||
"the prompt is held open instead of waiting for A and popping")
|
||||
prompt.stay.onShown()
|
||||
local menu = stack[#stack]
|
||||
check(getmetatable(menu) == Menu, "the prompt opens the heading menu")
|
||||
check(getmetatable(menu) == Menu, "the held prompt opens the heading menu")
|
||||
eq(stack[#stack - 1], prompt, "the menu sits on the still-visible prompt box")
|
||||
eq(#menu.items, 4, "four headings, as in HowToLinkText")
|
||||
local labels = {}
|
||||
for i, item in ipairs(menu.items) do labels[i] = item.label end
|
||||
eq(table.concat(labels, "/"),
|
||||
"HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING",
|
||||
"the headings read in HowToLinkText order")
|
||||
eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu")
|
||||
check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0,
|
||||
"the box is the asm's 15x10 at the top left")
|
||||
|
||||
for i = 1, 3 do
|
||||
stack = {}
|
||||
stack = { prompt, menu }
|
||||
menu.index = i
|
||||
eq(menu.items[i].keepOpen, true,
|
||||
labels[i] .. " leaves the menu up, as `jp .linkHelpLoop` does")
|
||||
menu.items[i].onSelect()
|
||||
local blurb = stack[#stack]
|
||||
check(getmetatable(blurb) == TextBox,
|
||||
labels[i] .. " prints a text box")
|
||||
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
|
||||
local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+")
|
||||
check(pages(blurb):find(want, 1, true) ~= nil,
|
||||
labels[i] .. " prints _LinkCableInfoText" .. i)
|
||||
check(type(blurb.onDone) == "function",
|
||||
labels[i] .. " returns to the prompt instead of dropping out")
|
||||
blurb.onDone()
|
||||
check(getmetatable(stack[#stack]) == TextBox,
|
||||
"the prompt comes back after " .. labels[i])
|
||||
stack[#stack].onDone()
|
||||
check(getmetatable(stack[#stack]) == Menu,
|
||||
"the menu comes back after " .. labels[i])
|
||||
eq(blurb.onDone, nil,
|
||||
labels[i] .. " pops itself back onto the menu instead of dropping out")
|
||||
table.remove(stack) -- the blurb pops itself once it has been read
|
||||
eq(stack[#stack], menu, "the same menu comes back after " .. labels[i])
|
||||
eq(menu.index, i, "the cursor stays on the heading that was just read")
|
||||
eq(stack[#stack - 1], prompt, "the held prompt is still under it")
|
||||
end
|
||||
|
||||
-- STOP READING and B share .exit: both close the menu and the prompt box
|
||||
eq(menu.items[4].keepOpen, nil, "STOP READING closes the menu")
|
||||
stack = { prompt, menu }
|
||||
table.remove(stack) -- Menu pops itself before a non-keepOpen onSelect runs
|
||||
menu.items[4].onSelect()
|
||||
eq(#stack, 0, "STOP READING closes the menu and the held prompt together")
|
||||
check(type(menu.onCancel) == "function", "B is watched (PAD_A | PAD_B)")
|
||||
stack = { prompt, menu }
|
||||
table.remove(stack)
|
||||
menu.onCancel()
|
||||
eq(#stack, 0, "B closes the menu and the held prompt together")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Parity test: the Pokemon Tower 6F Marowak ghost scene.
|
||||
--
|
||||
-- InitWildBattle runs the battle transition before the disguise
|
||||
-- (engine/battle/core.asm:6695-6702), _EnemyAppearedText carries no
|
||||
-- article (data/text/text_2.asm:1251-1255), and the CUBONE's-mother text
|
||||
-- ends in `done` so PlayCry lands on it (scripts/PokemonTower6F.asm:
|
||||
-- 137-148, text/PokemonTower6F.asm:1-5). See #1849.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ghost_marowak.lua`.
|
||||
-- Also picked up by tests/run_tests.lua's parity_* glob.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local S = require("tests.harness").suite("parity marowak scene")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
do
|
||||
local RealBattleState = require("src.battle.BattleState")
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { Pokemon.new(Data, "BULBASAUR", 30) },
|
||||
player = { name = "RED" },
|
||||
inventory = {},
|
||||
options = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
local b = RealBattleState.newWild(game, "MAROWAK", 30)
|
||||
b:makeGhost()
|
||||
eq(b.enemy.name, "GHOST", "the disguise renames the foe")
|
||||
eq(b.introText, "GHOST\nappeared!",
|
||||
"_EnemyAppearedText has no article in front of the nick")
|
||||
end
|
||||
|
||||
-- The script side: entry wipe and cry ordering.
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
local realBattleState = package.loaded["src.battle.BattleState"]
|
||||
|
||||
local pushed = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, cb)
|
||||
local box = { text = text, cb = cb }
|
||||
table.insert(pushed, box)
|
||||
return box
|
||||
end,
|
||||
substitute = function(_, text) return text end,
|
||||
}
|
||||
local battles = {}
|
||||
local stubBattle = {}
|
||||
stubBattle.__index = stubBattle
|
||||
function stubBattle:makeGhost() self.ghost = true end
|
||||
function stubBattle:makeUnveiledGhost() self.scopeReveal = true end
|
||||
package.loaded["src.battle.BattleState"] = {
|
||||
newWild = function(_, species, level)
|
||||
local b = setmetatable({ species = species, level = level }, stubBattle)
|
||||
table.insert(battles, b)
|
||||
return b
|
||||
end,
|
||||
}
|
||||
|
||||
local scripts = dofile("data/scripts/story3.lua")
|
||||
local onStep = scripts.POKEMON_TOWER_6F.onStep
|
||||
check(onStep ~= nil, "the tower 6F step trigger is registered")
|
||||
|
||||
local game = {
|
||||
data = { text = {} },
|
||||
save = { flags = {}, inventory = {} },
|
||||
stack = { push = function() end },
|
||||
}
|
||||
local rows
|
||||
local pushedBattles, stackBattles = 0, 0
|
||||
local ow = {
|
||||
pushBattle = function() pushedBattles = pushedBattles + 1 end,
|
||||
afterBattle = function() end,
|
||||
scriptMove = function() end,
|
||||
runner = { run = function(_, r) rows = r end },
|
||||
}
|
||||
game.stack.push = function(_, thing)
|
||||
if getmetatable(thing) == stubBattle then stackBattles = stackBattles + 1 end
|
||||
end
|
||||
|
||||
eq(onStep(game, ow, 10, 16), true, "stepping on the trigger cell fires it")
|
||||
eq(#pushed, 1, "the Be gone... line opens the scene")
|
||||
pushed[1].cb()
|
||||
eq(#battles, 1, "the ghost battle is built")
|
||||
eq(pushedBattles, 1, "the battle goes through the entry wipe (pushBattle)")
|
||||
eq(stackBattles, 0, "and is never pushed straight onto the stack")
|
||||
|
||||
battles[1].onFinish("win")
|
||||
check(rows ~= nil, "victory queues the departed script")
|
||||
eq(rows[1][1], "play_cry", "PlayCry is armed before the CUBONE's-mother box")
|
||||
eq(rows[1][2], "MAROWAK", "and it is the RESTLESS SOUL's cry")
|
||||
eq(rows[1][3], nil, "with no button wait -- that text ends in `done`")
|
||||
eq(rows[2][1], "show_text", "the CUBONE's-mother line follows")
|
||||
check(rows[2][2]:find("CUBONE") ~= nil, "and it is that line")
|
||||
eq(rows[3][1], "wait", "then the DelayFrames 30 gap")
|
||||
eq(rows[4][1], "show_text", "then the soul-was-calmed line")
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
package.loaded["src.battle.BattleState"] = realBattleState
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Parity test: Giovanni's "WHAT! This cannot be!" is an end-battle line.
|
||||
--
|
||||
-- RocketHideoutB4FGiovanniText arms it with SaveEndBattleTextPointers
|
||||
-- before EngageMapTrainer (scripts/RocketHideoutB4F.asm:99-120), so
|
||||
-- PrintEndBattleText prints it on the battle screen between
|
||||
-- TrainerDefeatedText and MoneyForWinningText. The port pushed it as an
|
||||
-- overworld TextBox from onFinish, i.e. after the map was back (#1817).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_giovanni_end_battle.lua`.
|
||||
-- Also picked up by tests/run_tests.lua's parity_* glob.
|
||||
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("parity giovanni end battle")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
local realBattleState = package.loaded["src.battle.BattleState"]
|
||||
|
||||
local pushed = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, cb)
|
||||
local box = { text = text, cb = cb }
|
||||
table.insert(pushed, box)
|
||||
return box
|
||||
end,
|
||||
substitute = function(_, text) return text end,
|
||||
}
|
||||
local battles = {}
|
||||
package.loaded["src.battle.BattleState"] = {
|
||||
newTrainer = function(_, class, index)
|
||||
local b = { trainerClass = class, partyIndex = index }
|
||||
table.insert(battles, b)
|
||||
return b
|
||||
end,
|
||||
}
|
||||
|
||||
local scripts = dofile("data/scripts/story3.lua")
|
||||
local handler = scripts.ROCKET_HIDEOUT_B4F.talk.TEXT_ROCKETHIDEOUTB4F_GIOVANNI
|
||||
check(handler ~= nil, "the Giovanni talk handler is registered")
|
||||
|
||||
local game = {
|
||||
data = { text = {} },
|
||||
save = { defeatedTrainers = {}, flags = {}, player = { name = "RED" } },
|
||||
stack = { push = function() end },
|
||||
}
|
||||
local ow = {
|
||||
trainerDefeated = function() return false end,
|
||||
pushBattle = function() end,
|
||||
afterBattle = function() end,
|
||||
}
|
||||
|
||||
handler(game, ow, { id = 7 }, function() end)
|
||||
eq(#pushed, 1, "the impressed line opens the scene")
|
||||
pushed[1].cb()
|
||||
eq(#battles, 1, "talking starts the Giovanni battle")
|
||||
local battle = battles[1]
|
||||
check(battle.endBattleText ~= nil,
|
||||
"the loss line is handed to the battle (SaveEndBattleTextPointers)")
|
||||
check(battle.endBattleText:find("cannot be") ~= nil,
|
||||
"and it is the WHAT!/This cannot be! text")
|
||||
|
||||
pushed = {}
|
||||
battle.onFinish("win")
|
||||
eq(#pushed, 1, "victory pushes exactly one overworld box")
|
||||
check(pushed[1].text:find("cannot be") == nil,
|
||||
"the cannot-be line no longer reprints on the map")
|
||||
check(pushed[1].text:find("meet") ~= nil,
|
||||
"BeatGiovanniScript's hope-we-meet-again text follows instead")
|
||||
eq(game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, true,
|
||||
"the beat-Giovanni event flag is set")
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
package.loaded["src.battle.BattleState"] = realBattleState
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Parity: HiddenCoins pays BCD constants, and the COIN+40 tile pays 20 (#1810).
|
||||
--
|
||||
-- engine/events/hidden_items.asm:79-96 branches the hidden-event argument
|
||||
-- into .bcd10 ($10), .bcd20 ($20) or .bcd100 ($0100); `cp 40 / jr z, .bcd20`
|
||||
-- is pokered's own typo, so .bcd40 at :90 ("due to a typo, this is never
|
||||
-- used") never runs and the Game Corner's COIN+40 tile (data/events/
|
||||
-- hidden_events.asm:294, GAME_CORNER 11,7) credits 20 coins on hardware.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_hidden_coins_bcd_bug1810.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("parity hidden coins bcd")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local pay = OverworldState.hiddenCoinPayout
|
||||
|
||||
eq(pay(10), 10, "COIN + 10 pays BCD $10")
|
||||
eq(pay(20), 20, "COIN + 20 pays BCD $20")
|
||||
eq(pay(40), 20, "COIN + 40 falls into .bcd20 and pays 20")
|
||||
eq(pay(100), 100, "COIN + 100 pays BCD $0100")
|
||||
eq(pay(0), 100, "anything the branch chain misses pays 100")
|
||||
|
||||
-- the generated data still carries the raw argument: the runtime mapping,
|
||||
-- not the extractor, is what turns the 40 tile into 20
|
||||
local field = dofile("data/generated/field.lua")
|
||||
local corner = field.hiddenCoins and field.hiddenCoins.GAME_CORNER
|
||||
check(corner ~= nil, "GAME_CORNER has hidden coin tiles")
|
||||
|
||||
local forty
|
||||
for _, h in ipairs(corner or {}) do
|
||||
if h.x == 11 and h.y == 7 then forty = h end
|
||||
end
|
||||
check(forty ~= nil, "the (11,7) hidden coin tile is present")
|
||||
eq(forty and forty.coins, 40, "it is extracted as the COIN + 40 argument")
|
||||
eq(pay(forty and forty.coins), 20, "and the award path credits 20 for it")
|
||||
|
||||
S.finish()
|
||||
@@ -138,4 +138,4 @@ do
|
||||
eq(#pushed, 0, "loss does not push after-battle text")
|
||||
end
|
||||
|
||||
print("parity_lance: ok")
|
||||
S.finish()
|
||||
|
||||
@@ -62,12 +62,15 @@ local function gameWith(inventory, flags)
|
||||
end
|
||||
|
||||
local function owWith()
|
||||
local moved, after = {}, {}
|
||||
local moved, after, wiped = {}, {}, {}
|
||||
return {
|
||||
player = { facing = "up" },
|
||||
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
|
||||
afterBattle = function(_, result) after[#after + 1] = result end,
|
||||
}, moved, after
|
||||
-- InitWildBattle runs the transition before the disguise
|
||||
-- (engine/battle/core.asm:6695-6702), so the script uses pushBattle
|
||||
pushBattle = function(_, battle) wiped[#wiped + 1] = battle end,
|
||||
}, moved, after, wiped
|
||||
end
|
||||
|
||||
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Parity: the Tower 2F rival scene arms its in-battle line, prints
|
||||
-- HowsYourDex on the win, and restores the map music (#1842).
|
||||
--
|
||||
-- PokemonTower2FRivalText arms SaveEndBattleTextPointers with .DefeatedText
|
||||
-- before the fight (scripts/PokemonTower2F.asm:145-150), the win re-runs
|
||||
-- DisplayTextID so the beaten branch prints .HowsYourDexText (asm:72-75,
|
||||
-- :137-140), and PokemonTower2FRivalExitsScript hides him and calls
|
||||
-- PlayDefaultMusic (asm:115-124).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_pokemon_tower_rival_bug1842.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("parity pokemon tower rival")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local story = dofile("data/scripts/story.lua")
|
||||
local tower = story.POKEMON_TOWER_2F
|
||||
check(tower ~= nil and tower.rivalScript ~= nil, "POKEMON_TOWER_2F rivalScript exists")
|
||||
|
||||
local rows = tower.rivalScript(15)
|
||||
|
||||
local function find(verb, arg)
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == verb and (arg == nil or row[2] == arg) then return i, row end
|
||||
end
|
||||
end
|
||||
|
||||
local iBattle = find("rival_battle")
|
||||
local iArm = find("save_end_battle_text", "_PokemonTower2FRivalDefeatedText")
|
||||
check(iArm ~= nil, "_PokemonTower2FRivalDefeatedText is armed for the battle")
|
||||
check(iBattle and iArm == iBattle - 1,
|
||||
"SaveEndBattleTextPointers runs just before the battle")
|
||||
check(find("show_text", "_PokemonTower2FRivalDefeatedText") == nil,
|
||||
"and it is not also printed on the map afterwards")
|
||||
|
||||
local iSet = find("set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL")
|
||||
local iDex = find("show_text", "_PokemonTower2FRivalHowsYourDexText")
|
||||
check(iSet and iDex and iDex > iSet,
|
||||
"the win prints HowsYourDex after setting the event")
|
||||
|
||||
local iHide = find("hide_object")
|
||||
local iDefault = find("play_default_music")
|
||||
check(iDefault ~= nil, "the exit calls play_default_music")
|
||||
check(iHide and iDefault and iDefault > iHide,
|
||||
"PlayDefaultMusic follows HideObject")
|
||||
local iWalk = find("walk_npc")
|
||||
check(iWalk and iHide and iHide > iWalk, "the walk-out runs before the hide")
|
||||
|
||||
-- the beaten branch: every jump target resolves, and talking again after the
|
||||
-- win reaches HowsYourDex instead of falling off the end
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local problems = ScriptRunner.validate(rows)
|
||||
eq(#problems, 0, "rival script validates: " .. table.concat(problems, "; "))
|
||||
|
||||
local iJumpIfTrue, jumpRow = find("jump_if_true")
|
||||
check(iJumpIfTrue ~= nil, "the beaten check jumps")
|
||||
local labelRow
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "label" and row[2] == (jumpRow and jumpRow[2]) then labelRow = i end
|
||||
end
|
||||
check(labelRow ~= nil, "it jumps to a real label, not a stale row number")
|
||||
local tail
|
||||
for i = labelRow or 0, #rows do
|
||||
if rows[i] and rows[i][1] == "show_text" then tail = rows[i][2] break end
|
||||
end
|
||||
eq(tail, "_PokemonTower2FRivalHowsYourDexText",
|
||||
"the beaten branch prints HowsYourDex")
|
||||
|
||||
-- both exit paths are still distinct (ON_LEFT vs not, asm:76-82)
|
||||
local left = tower.rivalScript(15)
|
||||
local right = tower.rivalScript(14)
|
||||
local _, lWalk = find("walk_npc")
|
||||
local rWalk
|
||||
for _, row in ipairs(right) do if row[1] == "walk_npc" then rWalk = row end end
|
||||
check(lWalk and rWalk and lWalk[3][1] ~= rWalk[3][1],
|
||||
"the two exit movements still differ")
|
||||
eq(#left, #right, "both variants are the same script shape")
|
||||
|
||||
S.finish()
|
||||
@@ -29,8 +29,11 @@ local Bag = require("src.inventory.Bag")
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
local realBag = package.loaded["src.ui.BagMenu"]
|
||||
local realParty = package.loaded["src.ui.PartyMenu"]
|
||||
-- soundOpts only builds an opts table, so the real one runs against the stub.
|
||||
local soundOpts = require("src.render.TextBox").soundOpts
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
soundOpts = soundOpts,
|
||||
}
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
package.loaded["src.ui.PartyMenu"] = nil
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Parity: Rocket3 in the Hideout B4F engages on sight, and the LIFT KEY
|
||||
-- still drops after a sight-triggered battle (#1814).
|
||||
--
|
||||
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
|
||||
-- (scripts/RocketHideoutB4F.asm:95-96) and the `trainer` macro stores arg 2
|
||||
-- as `db \2 << 4` (macros/scripts/maps.asm:107-127), so CheckSpriteCanSeePlayer
|
||||
-- (engine/overworld/trainer_sight.asm:257-262) engages him from one tile away
|
||||
-- through SCRIPT_ROCKETHIDEOUTB4F_DEFAULT (asm:42, home/trainers.asm:129).
|
||||
-- The port used to skip any trainer with a hand-ported talk script; only an
|
||||
-- explicit `noSight` opt-out may do that now. His LIFT KEY lives in
|
||||
-- Rocket3AfterBattleText (asm:189-199), i.e. the next talk, so a sight battle
|
||||
-- does not lose it.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_rocket3_sight_bug1814.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("parity rocket3 sight")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
|
||||
|
||||
-- the sight range the engine reads
|
||||
local headers = dofile("data/generated/trainer_headers.lua")
|
||||
local h = headers.RocketHideoutB4F and headers.RocketHideoutB4F[4]
|
||||
check(h ~= nil, "RocketHideoutB4F header 4 is extracted")
|
||||
eq(h and h.range, 1, "Rocket3's view range is one tile")
|
||||
|
||||
local maps = dofile("data/generated/maps.lua")
|
||||
local rocket3
|
||||
for _, o in ipairs(maps[MAP].objects or {}) do
|
||||
if o.text == TEXT then rocket3 = o end
|
||||
end
|
||||
check(rocket3 ~= nil, "Rocket3 is object " .. TEXT)
|
||||
eq(rocket3 and rocket3.index, 4, "he is object index 4, the header's row")
|
||||
check(rocket3 and rocket3.trainerClass ~= nil, "and he is a trainer sprite")
|
||||
eq(rocket3 and rocket3.movement, "STAY", "he holds his tile")
|
||||
eq(rocket3 and rocket3.range, "DOWN", "facing down, so the sight line is below him")
|
||||
eq(rocket3 and rocket3.x, 11, "standing at x 11")
|
||||
eq(rocket3 and rocket3.y, 2, "standing at y 2")
|
||||
|
||||
-- the gate the fix uses: having a talk script is NOT an opt-out; only an
|
||||
-- explicit noSight set is
|
||||
local story3 = dofile("data/scripts/story3.lua")
|
||||
local hideout = story3[MAP]
|
||||
check(hideout ~= nil and hideout.talk[TEXT] ~= nil,
|
||||
"Rocket3 still has his hand-ported talk script")
|
||||
check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true,
|
||||
"and he is not opted out of CheckFightingMapTrainers")
|
||||
|
||||
-- the LIFT KEY half: after the battle -- however it started -- the next talk
|
||||
-- reveals the ball
|
||||
-- put the real module back at the bottom: the tier dofiles every parity file
|
||||
-- into one process, so a stub left behind here breaks every later suite
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
soundOpts = function() return nil end,
|
||||
substitute = function(_, t) return t end,
|
||||
}
|
||||
local pushed
|
||||
local game = {
|
||||
data = { text = { _RocketHideoutB4FRocket3AfterBattleText = "Oh no!" } },
|
||||
save = { flags = {} },
|
||||
stack = { push = function(_, box) pushed = box end },
|
||||
}
|
||||
local ow = {
|
||||
map = { id = MAP, def = { objects = {} } },
|
||||
npcs = {}, entities = {},
|
||||
trainerDefeated = function() return true end,
|
||||
}
|
||||
local finished = false
|
||||
hideout.talk[TEXT](game, ow, { def = rocket3 }, function() finished = true end)
|
||||
check(pushed ~= nil, "talking after the win prints the after-battle text")
|
||||
if pushed and pushed.done then pushed.done() end
|
||||
check(game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
|
||||
"EVENT_ROCKET_DROPPED_LIFT_KEY is set")
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles[MAP]
|
||||
check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true,
|
||||
"the LIFT KEY ball is shown")
|
||||
check(finished, "and the talk hands control back")
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
|
||||
S.finish()
|
||||
@@ -64,16 +64,22 @@ do
|
||||
"player at x=15 uses DownThenRightMovement")
|
||||
end
|
||||
|
||||
-- (2) win path: flag, defeat text, walk, hide -- in order
|
||||
-- (2) win path: flag, post-win text, walk, hide -- in order.
|
||||
-- DefeatedText rides SaveEndBattleTextPointers, so it prints in battle and the
|
||||
-- map-side box after the win is HowsYourDex (#1842).
|
||||
-- scripts/PokemonTower2F.asm:71-75, :137-140
|
||||
do
|
||||
local rows = tower.rivalScript(14)
|
||||
local preds = {
|
||||
{ "save_end_battle_text DefeatedText",
|
||||
function(r) return r[1] == "save_end_battle_text"
|
||||
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
|
||||
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
|
||||
function(r) return r[1] == "set_flag"
|
||||
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
|
||||
{ "show_text DefeatedText",
|
||||
{ "show_text HowsYourDexText",
|
||||
function(r) return r[1] == "show_text"
|
||||
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
|
||||
and r[2] == "_PokemonTower2FRivalHowsYourDexText" end },
|
||||
{ "walk_npc exit",
|
||||
function(r) return r[1] == "walk_npc" end },
|
||||
{ "hide_object POKEMONTOWER2F_RIVAL",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user