-- Yellow starts Bill's House with Pikachu's confused reaction. The map -- script owns that one-shot, while PikachuFollower owns the movement. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("parity Yellow Bill's Pikachu") local check = S.check local entered = 0 local originalFollower = package.loaded["src.world.PikachuFollower"] package.loaded["src.world.PikachuFollower"] = { onBillsHouseEnter = function() entered = entered + 1 end, } local story = dofile("data/scripts/story.lua") local game = { save = { flags = {} } } story.BILLS_HOUSE.onEnter(game, {}) check(entered == 1, "entering Bill's House before meeting Bill starts Pikachu's reaction") entered = 0 game.save.flags.EVENT_MET_BILL_2 = true story.BILLS_HOUSE.onEnter(game, {}) check(entered == 0, "Pikachu's Bill reaction does not replay after Bill is met") package.loaded["src.world.PikachuFollower"] = originalFollower local GameVersion = require("src.core.GameVersion") local PikachuFollower = require("src.world.PikachuFollower") GameVersion.set("yellow") local npc = { pikachuFollower = true, cellX = 3, cellY = 8, px = 48, py = 128, facing = "up", -- pokeyellow engine/pikachu/pikachu_follow.asm:26 passable = true, } local Sound = require("src.core.Sound") local realPikaCry = Sound.playPikaCry local cry Sound.playPikaCry = function(_, index) cry = index return true end local moves = {} local yellowGame = { save = { flags = {} }, data = { pokemon = { PIKACHU = { spriteFront = "pikachu.png" } }, field = { emotionBubbles = { bubbles = { { name = "QUESTION_BUBBLE" }, { name = "EXCLAMATION_BUBBLE" }, }, } }, }, } local ow = { map = { id = "BILLS_HOUSE" }, npcs = { npc }, entities = { npc }, player = { cellX = 3, cellY = 7 }, scriptMove = function(_, entity, dir, tiles, onDone) moves[#moves + 1] = { entity = entity, dir = dir, tiles = tiles, onDone = onDone } end, } PikachuFollower.onBillsHouseEnter(yellowGame, ow) check(ow.pikachuBillsScene and #moves == 1 and moves[1].entity == npc and moves[1].dir == "right" and moves[1].tiles == 3, "Bill's House entry parks Pikachu and walks it to Bill") moves[1].onDone() check(#moves == 2 and moves[2].dir == "up" and moves[2].tiles == 1, "Pikachu finishes its cartridge entry route beside Bill") moves[2].onDone() check(ow.emote and ow.emote.bubble == 1 and ow.emote.frames == 60 and not ow.emote.pikaPic, "Pikachu shows its confused reaction after reaching Bill") -- emotion 23 -- engine/pikachu/pikachu_emotions.asm:14 check(npc.stepFrames == 16, "the scripted walk runs at the Pikachu movement step length") ow.emote.onDone() check(ow.emote and ow.emote.pikaPic and ow.emote.frames > 0 and not ow.emote.skippable, "and the bubble hands off to the unskippable emotion animation") -- BillsHouseScript3 seeds hl with ..._EnterCellSeparatorDown and only swaps -- in ..._EnterCellSeparatorNotDown on the facing-down fallthrough, so the -- table names are inverted relative to the branch that picks them: facing -- down takes the five-step detour, every other facing walks straight up -- (pokeyellow scripts/BillsHouse.asm:100-133) (#455). ow.player.facing = "down" PikachuFollower.onBillEnteredMachine(yellowGame, ow) check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 1, "facing down, Pikachu starts the long way round the cell separator") moves[3].onDone() check(#moves == 4 and moves[4].dir == "left" and moves[4].tiles == 1, "the detour steps aside before climbing") moves[4].onDone() check(#moves == 5 and moves[5].dir == "up" and moves[5].tiles == 2, "the detour climbs past the separator") moves[5].onDone() check(#moves == 6 and moves[6].dir == "right" and moves[6].tiles == 1, "the detour steps back in beside it") moves[6].onDone() -- InitializePikachuTextID -- scripts/BillsHouse.asm:100 check(ow.emote and ow.emote.bubble == false and ow.emote.pikaPic and npc.facing == "up", "Pikachu looks up at the cell separator with no bubble, just the emotion") -- the other branch: any non-down facing takes the straight three-step route local before = #moves ow.player.facing = "up" PikachuFollower.onBillEnteredMachine(yellowGame, ow) check(#moves == before + 1 and moves[before + 1].dir == "up" and moves[before + 1].tiles == 3, "facing up, Pikachu walks straight to the cell separator") moves[before + 1].onDone() npc.goalX, npc.goalY = 9, 9 PikachuFollower.update(yellowGame, ow) check(npc.goalX == 9 and npc.goalY == 9, "Pikachu stays parked in Bill's House during the scene") PikachuFollower.onBillExitedMachine(yellowGame, ow) check(ow.emote and ow.emote.bubble == false and ow.emote.frames == 12 and npc.facing ~= "left", "SetSpriteFacingDirectionAndDelay + DELAY/LOOK_LEFT hold before the bubble") ow.emote.onDone() check(ow.emote and ow.emote.bubble == 2 and ow.emote.frames == 60 and npc.facing == "left", "Pikachu reacts when Bill comes back out") ow.emote.onDone() check(ow.emote and ow.emote.pikaPic, "and emotion 27 plays behind that bubble") -- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156) is the -- other side of BillsHouseScript2: it only runs while Pikachu still follows -- the player, and TryApplyPikachuMovementData keys each table on -- GetPikachuFacingDirectionAndReturnToE, which is Pikachu's position -- relative to the player rather than its facing byte, so WatchPlayer1 wants -- Pikachu above the player and WatchPlayer2 wants it level and east (#455). local function placePikachu(cx, cy) npc.cellX, npc.cellY, npc.facing = cx, cy, "down" end ow.pikachuBillsScene = nil moves = {} placePikachu(ow.player.cellX, ow.player.cellY - 1) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 1 and moves[1].dir == "left" and moves[1].tiles == 1, "a Pikachu standing above the player steps aside for Bill's detour") moves[1].onDone() check(#moves == 2 and moves[2].dir == "down" and moves[2].tiles == 1, "the watch route drops clear of Bill's detour") moves[2].onDone() check(npc.facing == "right", "PIKAMOVEMENT_LOOK_RIGHT watches the player") moves = {} placePikachu(ow.player.cellX + 1, ow.player.cellY) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 1 and moves[1].dir == "up" and moves[1].tiles == 1, "level with and east of the player takes the longer WatchPlayer2 route") moves[1].onDone() check(#moves == 2 and moves[2].dir == "left" and moves[2].tiles == 2, "WatchPlayer2 crosses two cells west") moves[2].onDone() check(#moves == 3 and moves[3].dir == "down" and moves[3].tiles == 1, "WatchPlayer2 drops back level with the player") moves[3].onDone() check(npc.facing == "right", "WatchPlayer2 ends watching the player too") -- below the player is SPRITE_FACING_DOWN and west of it is -- SPRITE_FACING_LEFT, and neither call asks for those moves = {} placePikachu(ow.player.cellX, ow.player.cellY + 1) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 0, "a Pikachu trailing from the south has no watch route") placePikachu(ow.player.cellX - 1, ow.player.cellY) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 0, "nor does one already standing west of the player") -- ApplyPikachuMovementData_ finishes a table before the second -- TryApplyPikachuMovementData reads the geometry again, but WatchPlayer1 -- lands on (playerX - 1, playerY), so WatchPlayer2 can never chain onto it moves = {} placePikachu(ow.player.cellX, ow.player.cellY - 1) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) moves[1].onDone() moves[2].onDone() placePikachu(ow.player.cellX - 1, ow.player.cellY) moves = {} PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 0, "the two tables stay mutually exclusive by geometry") -- the parked Pikachu of the confused beat is the not-following side of -- CheckPikachuFollowingPlayer, so it never watches ow.pikachuBillsScene = true placePikachu(ow.player.cellX, ow.player.cellY - 1) PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) check(#moves == 0, "the parked Pikachu of the confused beat stays put") -- BillsHouseScript0 skips the whole entry beat for a statused starter -- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46) ow.pikachuBillsScene = nil yellowGame.save.pikachuMapScriptActive = nil moves = {} yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } } PikachuFollower.onBillsHouseEnter(yellowGame, ow) check(not ow.pikachuBillsScene and #moves == 0, "a statused starter keeps following instead of walking over to Bill") yellowGame.save.party = nil -- of the starter-status and mood fallbacks -- scripts/BillsHouse_2.asm:88 npc.facePlayer = function(self) self.facing = "up" end ow.player.facing = "down" ow.pikachuBillsScene = true PikachuFollower.talk(yellowGame, ow, npc) check(cry == 19, "talking during the confused beat picks emotion 23") ow.pikachuBillsScene = nil PikachuFollower.talk(yellowGame, ow, npc) check(cry == 26, "before Bill is met again it is emotion 32") yellowGame.save.flags.EVENT_MET_BILL_2 = true PikachuFollower.talk(yellowGame, ow, npc) check(cry == 19, "and emotion 31 once EVENT_MET_BILL_2 is set") yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "SLP" } } PikachuFollower.talk(yellowGame, ow, npc) check(cry == 19, "the map check still wins over a statused starter") yellowGame.save.party = nil yellowGame.save.flags.EVENT_MET_BILL_2 = nil Sound.playPikaCry = realPikaCry -- CheckPikachuFollowingPlayer is one flag, and CollisionCheckOnLand blocks -- the player outright while it is set (home/overworld.asm:1238-1240). All -- three scenes that call DisablePikachuFollowingPlayer must park a solid -- companion: scripts/BillsHouse_2.asm:121, PokemonFanClub.asm:60, -- PewterPokecenter_2.asm:67. for _, scene in ipairs({ "pikachuBillsScene", "pikachuFanClubScene", "pikachuPewterSleepScene" }) do -- update() drops the follower once shouldSpawn goes false, so re-seed it ow.npcs, ow.entities = { npc }, { npc } npc.passable = true ow[scene] = true PikachuFollower.update(yellowGame, ow) check(not npc.passable, scene .. " parks a solid companion") ow[scene] = nil PikachuFollower.update(yellowGame, ow) check(npc.passable, scene .. " ending lets the player walk through again") end -- The other two states of CollisionCheckOnLand's Pikachu branch -- (home/overworld.asm:1234-1252): B held walks straight through, and -- wPikachuCollisionCounter is a soft 8-count bump seeded on a direction -- change (:189) and cleared with no d-pad held (:130) or once a step -- commits (:242). local held = {} yellowGame.input = { isDown = function(_, b) return held[b] == true end } local cp = ow.player cp.cellX, cp.cellY, cp.facing, cp.moving = 3, 7, "up", false npc.cellX, npc.cellY = 3, 6 local function tick() ow.npcs, ow.entities = { npc }, { npc } PikachuFollower.update(yellowGame, ow) end held = {} tick() check(npc.passable and ow.pikachuCollisionCounter == 0, "no d-pad held clears the counter and the companion is walk-through") held = { up = true } tick() check(ow.pikachuCollisionCounter == 8 and not npc.passable, "turning to a new direction seeds the 8-count bump") local blocked = 0 for _ = 1, 8 do tick() if not npc.passable then blocked = blocked + 1 end end check(blocked == 7, "the bump blocks seven pushes and then yields, like dec [hl] / jr nz") check(npc.passable and ow.pikachuCollisionCounter == 0, "the drained counter leaves the companion passable") held = {} tick() held = { up = true } tick() check(ow.pikachuCollisionCounter == 0, "re-pressing the same direction after a release does not re-seed") held = {} tick() held = { left = true } tick() check(ow.pikachuCollisionCounter == 8, "a new direction seeds again") held = { left = true, b = true } tick() check(npc.passable, "holding B walks straight through the bump") held = { left = true } cp.moving = true tick() check(ow.pikachuCollisionCounter == 0 and npc.passable, "a committed step clears the counter") cp.moving = false held = {} tick() yellowGame.input = nil -- engine/pikachu/pikachu_follow.asm:227,119 check(PikachuFollower.warpSpawnState(true, "ROUTE_25", "BILLS_HOUSE", false, "up") == 1, "Bill's House door from Route 25 is spawn state 1") local emergeMoves = {} local emergeOw = { map = { id = "BILLS_HOUSE", def = { tileset = "INTERIOR" }, inBounds = function() return true end, isWalkableCell = function() return true end, isWaterCell = function() return false end, cellTile = function() return 0 end, }, npcs = {}, entities = {}, player = { cellX = 2, cellY = 7, facing = "up" }, scriptMove = function(_, entity, dir, tiles, onDone) emergeMoves[#emergeMoves + 1] = { dir = dir, tiles = tiles, onDone = onDone } end, } local emergeNpc = { pikachuFollower = true, cellX = 3, cellY = 7, px = 48, py = 112, facing = "up", passable = true, } emergeOw.npcs[1], emergeOw.entities[1] = emergeNpc, emergeNpc yellowGame.save.flags = {} yellowGame.save.pikachuMapScriptActive = nil PikachuFollower.onBillsHouseEnter(yellowGame, emergeOw) check(#emergeMoves == 1 and emergeMoves[1].dir == "right" and emergeMoves[1].tiles == 3, "the confused walk runs PikachuMovement_Confused's three STEP_RIGHTs first") emergeMoves[1].onDone() check(#emergeMoves == 2 and emergeMoves[2].dir == "up" and emergeMoves[2].tiles == 1, "and ends one cell up, directly below Bill") -- engine/overworld/player_animations.asm:37 PikachuFollower.placeAtSpawnState(emergeOw, 1) check(emergeNpc.cellX == 3 and emergeNpc.cellY == 7 and emergeNpc.facing == "up", "a warp-pad arrival puts the companion back east of the player") PikachuFollower.placeAtSpawnState(emergeOw, 3) check(emergeNpc.cellX == 2 and emergeNpc.cellY == 7 and emergeNpc.facing == "down", "and state 3 is the player's own cell facing down") GameVersion.set("red") S.finish()