diff --git a/data/scripts/ai_classes.lua b/data/scripts/ai_classes.lua index 59d84470..eb22543c 100644 --- a/data/scripts/ai_classes.lua +++ b/data/scripts/ai_classes.lua @@ -7,7 +7,7 @@ -- random byte). hpBelow means "only when HP < max/N". onStatus means -- "always, but only when the active mon has a status condition". -return { +local classes = { OPP_JUGGLER = { uses = 3, chance = 64, switch = true }, OPP_BLACKBELT = { uses = 2, chance = 32, item = "X_ATTACK" }, OPP_GIOVANNI = { uses = 1, chance = 64, item = "GUARD_SPEC" }, @@ -31,3 +31,14 @@ return { item = "SUPER_POTION", hpBelow = 4 }, OPP_LANCE = { uses = 1, chance = 128, item = "HYPER_POTION", hpBelow = 5 }, } + +if require("src.core.GameVersion").isYellow() then + -- pokeyellow engine/battle/trainer_ai.asm:391 + classes.OPP_KOGA = { uses = 2, chance = 32, item = "X_ATTACK" } + -- pokeyellow engine/battle/trainer_ai.asm:396 + classes.OPP_BLAINE = { uses = 2, chance = 64, item = "SUPER_POTION", hpBelow = 10 } + -- pokeyellow engine/battle/trainer_ai.asm:404 + classes.OPP_SABRINA = { uses = 1, chance = 64, item = "X_DEFEND" } +end + +return classes diff --git a/data/scripts/flavor/bike_shop.lua b/data/scripts/flavor/bike_shop.lua index 2d63c950..d85f3ced 100644 --- a/data/scripts/flavor/bike_shop.lua +++ b/data/scripts/flavor/bike_shop.lua @@ -34,20 +34,16 @@ return { { "show_text", "_BikeShopMiddleAgedWomanText" }, }, - -- BikeShopYoungsterText (pokered/scripts/BikeShop.asm): - -- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike - -- before the player owns a bike -> TheseBikesAreExpensiveText - -- after the player owns a bike -> CoolBikeText - -- The check reads the bag, not the event: the port hands out the - -- BICYCLE itself (a key item, so it cannot be tossed), and that also - -- reads right on saves written before the clerk set the event (#567). + -- scripts/BikeShop.asm:134 TEXT_BIKESHOP_YOUNGSTER = { { "face_player" }, -- 1 - { "check_item", "BICYCLE" }, -- 2 - { "jump_if_true", 6 }, -- 3 - { "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4 - { "jump", "end" }, -- 5 - { "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6 + { "check_flag", "EVENT_GOT_BICYCLE" }, -- 2 + { "jump_if_true", 8 }, -- 3 + { "check_item", "BICYCLE" }, -- 4 + { "jump_if_true", 8 }, -- 5 + { "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 6 + { "jump", "end" }, -- 7 + { "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 8 }, }, }, diff --git a/data/scripts/flavor/game_corner.lua b/data/scripts/flavor/game_corner.lua index 82ee7002..e791b5b1 100644 --- a/data/scripts/flavor/game_corner.lua +++ b/data/scripts/flavor/game_corner.lua @@ -24,7 +24,9 @@ local function coinGiver(opts) push("_GameCornerOopsForgotCoinCaseText", "Oops! Forgot the\nCOIN CASE!") return end - if (game.save.coins or 0) >= 9990 then + local coins = game.save.coins or 0 + if (opts.exact9990 and coins == 9990) + or (not opts.exact9990 and coins >= 9990) then push(opts.coinCaseFullLabel, opts.coinCaseFullFallback) return end @@ -69,15 +71,11 @@ return { alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!", }), - -- GameCornerGentlemanText (pokered/scripts/GameCorner.asm): gives 20 - -- coins once (EVENT_GOT_20_COINS). Note the original ASM uses - -- Has9990Coins' `jr z` (only the exact-9990 case) here rather than - -- Clerk1/Clerk2's `jr nc` (>=9990); ported as >=9990 to match their - -- "coin case is basically full" intent and avoid a coin-count - -- edge case where 9991-9999 would otherwise slip past the check. + -- pokered/scripts/GameCorner.asm:378 TEXT_GAMECORNER_GENTLEMAN = coinGiver({ event = "EVENT_GOT_20_COINS", amount = 20, + exact9990 = true, askLabel = "_GameCornerGentlemanThrowingMeOffText", askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!", receivedLabel = "_GameCornerGentlemanReceived20CoinsText", @@ -116,9 +114,11 @@ return { alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!", }), + -- pokeyellow/scripts/GameCorner.asm:382 TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({ event = "EVENT_GOT_20_COINS", amount = 20, + exact9990 = true, askLabel = "_GameCornerFishingGuru2ThrowingMeOffText", askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!", receivedLabel = "_GameCornerFishingGuru2Received20CoinsText", diff --git a/data/scripts/flavor/mr_fujis_house.lua b/data/scripts/flavor/mr_fujis_house.lua index 4022add5..93ab7ad1 100644 --- a/data/scripts/flavor/mr_fujis_house.lua +++ b/data/scripts/flavor/mr_fujis_house.lua @@ -6,6 +6,13 @@ local M = {} M.MR_FUJIS_HOUSE = { + -- data/events/hidden_events.asm:515-517 + onInteract = function(game, ow, fx, fy) + if fy ~= 1 or not (fx == 0 or fx == 1 or fx == 7) then return false end + game.stack:push(require("src.render.TextBox").new(game, + game.data.text._MagazinesText)) + return true + end, talk = { -- scripts/MrFujisHouse.asm MrFujisHouseSuperNerdText: CheckEvent -- EVENT_RESCUED_MR_FUJI branches between "he's not here" and diff --git a/data/scripts/flavor/route_15_gate_2f.lua b/data/scripts/flavor/route_15_gate_2f.lua index ee1b3784..58defb2a 100644 --- a/data/scripts/flavor/route_15_gate_2f.lua +++ b/data/scripts/flavor/route_15_gate_2f.lua @@ -9,5 +9,17 @@ return { { "show_text", "_Route15Gate2FBinocularsText" }, }, }, + -- engine/events/hidden_events/route_15_binoculars.asm:1-10 + onInteract = function(game, ow, fx, fy) + if fx ~= 1 or fy ~= 2 or ow.player.facing ~= "up" then return false end + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._Route15UpstairsBinocularsText, function() + require("src.core.Sound").playCry(game.data, "ARTICUNO") + game.stack:push(require("src.ui.PicBox").new(game, + "assets/generated/battle/front/articuno.png")) + end)) + return true + end, }, } diff --git a/data/scripts/flavor/viridian_forest.lua b/data/scripts/flavor/viridian_forest.lua new file mode 100644 index 00000000..9d115fdb --- /dev/null +++ b/data/scripts/flavor/viridian_forest.lua @@ -0,0 +1,10 @@ +return { + VIRIDIAN_FOREST = { + talk = { + -- pokeyellow/scripts/ViridianForest.asm:169-171 + TEXT_VIRIDIANFOREST_LEAVING_SIGN = { + { "show_text", "_ViridianForestTrainerTips1Text" }, + }, + }, + }, +} diff --git a/data/scripts/init.lua b/data/scripts/init.lua index 71fc91fb..a43639f5 100644 --- a/data/scripts/init.lua +++ b/data/scripts/init.lua @@ -51,7 +51,9 @@ if GameVersion.isYellow() then "data.scripts.yellow_jessie_james", "data.scripts.yellow_beach_house", "data.scripts.yellow_viridian_old_man", - "data.scripts.flavor.pokecenter_chansey" }) do + "data.scripts.yellow_npcs", + "data.scripts.flavor.pokecenter_chansey", + "data.scripts.flavor.viridian_forest" }) do for mapId, mod in pairs(require(file)) do MapScripts.attachBase(mapId, mod) end diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index acd9a845..4562e3e9 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -296,6 +296,9 @@ return { if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER and y >= 6 then ow.runner:run({ + -- scripts/OaksLab.asm:158-167 + { "face_object", 5, "down" }, + { "face_object", 1, "down" }, { "show_text", "_OaksLabOakDontGoAwayYetText" }, { "move_player", "up", 1 }, }, {}) diff --git a/data/scripts/safari.lua b/data/scripts/safari.lua index 494655e3..00eac32c 100644 --- a/data/scripts/safari.lua +++ b/data/scripts/safari.lua @@ -70,6 +70,12 @@ local function startGame(game, ow, t, done, balls, introText) opts = TextBox.soundOpts(game, "Get_Item1", opts) end game.stack:push(TextBox.new(game, paid, function() + -- pokeyellow scripts/SafariZoneGate_2.asm:57 + if introText then + if done then done() end + walkIntoZone(game, ow) + return + end game.stack:push(TextBox.new(game, pa, function() if done then done() end walkIntoZone(game, ow) @@ -152,17 +158,11 @@ end M.SAFARI_ZONE_GATE = { talk = { + -- scripts/SafariZoneGate.asm:144-146 TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER1 = function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local t = game.data.text - if game.save.safari then - game.stack:push(TextBox.new(game, - t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!", done)) - return - end game.stack:push(TextBox.new(game, - t._SafariZoneGateSafariZoneWorker1Text or "Welcome to the\nSAFARI ZONE!", - function() joinPrompt(game, ow, done) end)) + game.data.text._SafariZoneGateSafariZoneWorker1Text, done)) end, }, diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index 0f20c72a..d2b7a292 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -64,4 +64,43 @@ for mapId, holes in pairs(HOLE_FALLS) do end end +-- scripts/SeafoamIslands1F.asm:3 +M.SEAFOAM_ISLANDS_1F.onEnter = function(game) + game.save.flags.EVENT_IN_SEAFOAM_ISLANDS = true +end + +-- scripts/Route20.asm:2-49 +M.ROUTE_20 = { + onEnter = function(game, ow) + local f = game.save.flags + if not f.EVENT_IN_SEAFOAM_ISLANDS then return end + f.EVENT_IN_SEAFOAM_ISLANDS = nil + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + if not (f.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE + and f.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE) then + Commands.show_object(ctx, "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1") + Commands.show_object(ctx, "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2") + for _, o in ipairs({ + { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER1" }, + { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER2" }, + { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER1" }, + { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER2" }, + { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER5" }, + { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER6" }, + }) do + Commands.hide_object(ctx, o[1], o[2]) + end + end + if f.EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE + and f.EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE then + return + end + Commands.show_object(ctx, "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER2") + Commands.show_object(ctx, "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER3") + Commands.hide_object(ctx, "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1") + Commands.hide_object(ctx, "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2") + end, +} + return M diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 376a94b0..bb55748f 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -154,7 +154,7 @@ M.BLUES_HOUSE = { { "show_text", "_BluesHouseDaisyOfferMapText" }, -- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the -- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText) - { "give_item", "TOWN_MAP", 1, false }, + { "give_item", "TOWN_MAP", 1, false, "_BluesHouseDaisyBagFullText" }, { "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" }, { "show_text", "_GotMapText" }, { "set_flag", "EVENT_GOT_TOWN_MAP" }, @@ -340,6 +340,11 @@ M.VERMILION_CITY = { local puz = game.save.trashPuzzle or {} game.save.trashPuzzle = puz puz.first = love.math.random(0, 7) * 2 + -- pokeyellow scripts/VermilionCity.asm:20 + if require("src.core.GameVersion").isYellow() + and game.save.flags.EVENT_RECEIVED_BIKE_VOUCHER then + game.save.flags.EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER = true + end end, -- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check: -- the unguarded cell (18,30) just west of the sailor leads straight @@ -564,6 +569,9 @@ M.POKEMON_TOWER_7F = { Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name) end end + if game.save.flags.EVENT_RESCUED_MR_FUJI then + Commands.hide_object(ctx, "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI") + end end, -- runVictoryHook fires after every trainer win on the map (both the sight -- and talk paths route through engageTrainer -> checkVictoryRewards), so @@ -599,6 +607,8 @@ M.POKEMON_TOWER_7F = { { "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5 { "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6 { "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7 + -- scripts/PokemonTower7F.asm:70-72 + { "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI" }, -- 8 -- pokered warps to wDestinationWarpID $1 (0-based) -- the house's -- SECOND warp, the door mat at (3,7) -- facing UP -- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP + @@ -609,7 +619,7 @@ M.POKEMON_TOWER_7F = { -- collected and the Route 16 SNORLAX sealed the map. The arrival -- mat itself is inert until stepped off (warpEntryCell), which is -- what makes the vanilla coordinates safe. - { "warp", "MR_FUJIS_HOUSE", 3, 7, "up", true }, -- 8 + { "warp", "MR_FUJIS_HOUSE", 3, 7, "up", true }, -- 9 }, }, } @@ -632,7 +642,8 @@ M.MR_FUJIS_HOUSE = { { "jump_if_false", 14 }, -- 5 { "show_text", "_MrFujisHouseMrFujiIThinkThisMayHelpYourQuestText" }, -- 6 -- give-then-print like scripts/MrFujisHouse.asm - { "give_item", "POKE_FLUTE", 1, false }, -- 7 + { "give_item", "POKE_FLUTE", 1, false, + "_MrFujisHouseMrFujiPokeFluteNoRoomText" }, -- 7 { "show_text", "_MrFujisHouseMrFujiReceivedPokeFluteText" }, -- 8 { "set_flag", "EVENT_GOT_POKE_FLUTE" }, -- 9 { "show_text", "_MrFujisHouseMrFujiPokeFluteExplanationText" }, -- 10 @@ -718,7 +729,8 @@ M.SAFARI_ZONE_SECRET_HOUSE = { { "jump_if_true", 9 }, -- 3 { "show_text", "_SafariZoneSecretHouseFishingGuruYouHaveWonText" }, -- 4 -- give-then-print like scripts/SafariZoneSecretHouse.asm - { "give_item", "HM_SURF", 1, false }, -- 5 + { "give_item", "HM_SURF", 1, false, + "_SafariZoneSecretHouseFishingGuruHM03NoRoomText" }, -- 5 { "show_text", "_SafariZoneSecretHouseFishingGuruReceivedHM03Text" }, -- 6 { "set_flag", "EVENT_GOT_HM03" }, -- 7 { "jump", 10 }, -- 8 @@ -881,6 +893,8 @@ M.SILPH_CO_11F = { or "Ah {PLAYER}!\nSo we meet again!", function() ow:scriptMove(gio, "down", 3, function() + -- scripts/SilphCo11F.asm:244-255 + ow.player.facing = (x == 7 and y == 12) and "up" or "left" gio:facePlayer(ow.player) ow:engageTrainer(gio, function() -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" @@ -917,7 +931,8 @@ M.SILPH_CO_11F = { { "jump_if_true", 9 }, -- 3 { "show_text", "_SilphCo11FSilphPresidentText" }, -- 4 -- give-then-print like scripts/SilphCo11F.asm - { "give_item", "MASTER_BALL", 1, false, false, "Get_Key_Item" }, -- 5 scripts/SilphCo11F.asm:322 + { "give_item", "MASTER_BALL", 1, false, + "_SilphCo11FSilphPresidentNoRoomText", "Get_Key_Item" }, -- 5 scripts/SilphCo11F.asm:322 { "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6 { "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7 { "jump", "end" }, -- 8 @@ -1118,6 +1133,8 @@ local championsRoomRivalScript = { { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15 { "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement -- OakCongratulatesPlayerScript: rival faces left, Oak faces down + -- scripts/ChampionsRoom.asm:142-143 + { "face_player_dir", "left" }, { "face_object", 1, "left" }, -- 17 { "face_object", 2, "down" }, -- 18 { "load_player_starter_name" }, diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 663273b6..a14f324e 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -348,7 +348,7 @@ local function takeGuardDrink(game) return false end -local function saffronGate(guardText, triggers, horizontal) +local function saffronGate(guardText, triggers, horizontal, face) return { talk = { [guardText] = function(game, ow, npc, done) @@ -386,21 +386,18 @@ local function saffronGate(guardText, triggers, horizontal) if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end local TextBox = require("src.render.TextBox") local t = game.data.text - -- Stepping on the trigger WITH a drink hands it over right here. - -- + local back + if horizontal then + back = ow.player.facing == "left" and "right" or "left" + else + back = ow.player.facing == "up" and "down" or "up" + end + -- scripts/Route7Gate.asm:32-33 + if face then ow.player.facing = face end -- Route5GateDefaultScript (scripts/Route5Gate.asm) runs -- `farcall RemoveGuardDrink` before it decides anything: the coord -- trigger itself takes the drink and sets -- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing - -- gets the thirsty line and the walk-back. We had the removal on the - -- guard's TALK handler only, so walking up with a FRESH_WATER in the - -- bag was turned away and the four gates stayed shut unless you - -- happened to talk to him -- which vanilla never requires. - -- - -- Saffron is the middle of the map, so this sealed it: every route - -- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the - -- short way) was unreachable, and the bot could not get to Lavender - -- for the POKE_FLUTE at all. if takeGuardDrink(game) then game.stack:push(TextBox.new(game, t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!", @@ -411,12 +408,6 @@ local function saffronGate(guardText, triggers, horizontal) end, TextBox.soundOpts(game, "Get_Key_Item"))) return true end - local back - if horizontal then - back = ow.player.facing == "left" and "right" or "left" - else - back = ow.player.facing == "up" and "down" or "up" - end game.stack:push(TextBox.new(game, t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.", function() @@ -427,10 +418,15 @@ local function saffronGate(guardText, triggers, horizontal) } end -M.ROUTE_5_GATE = saffronGate("TEXT_ROUTE5GATE_GUARD", { { 3, 3 }, { 4, 3 } }) -M.ROUTE_6_GATE = saffronGate("TEXT_ROUTE6GATE_GUARD", { { 3, 2 }, { 4, 2 } }) -M.ROUTE_7_GATE = saffronGate("TEXT_ROUTE7GATE_GUARD", { { 3, 3 }, { 3, 4 } }, true) -M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, true) +-- scripts/Route5Gate.asm:26 +M.ROUTE_5_GATE = saffronGate("TEXT_ROUTE5GATE_GUARD", { { 3, 3 }, { 4, 3 } }, + false, "left") +M.ROUTE_6_GATE = saffronGate("TEXT_ROUTE6GATE_GUARD", { { 3, 2 }, { 4, 2 } }, + false, "right") +M.ROUTE_7_GATE = saffronGate("TEXT_ROUTE7GATE_GUARD", { { 3, 3 }, { 3, 4 } }, + true, "up") +M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, + true, "left") -- ------------------------------------------------------------------- -- Bike Voucher chain (scripts/PokemonFanClub.asm, BikeShop.asm) @@ -451,7 +447,8 @@ M.POKEMON_FAN_CLUB = { { "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6 -- give-then-print like scripts/PokemonFanClub.asm (GiveItem -- fills wStringBuffer; the received text reads it) - { "give_item", "BIKE_VOUCHER", 1, false }, -- 7 + { "give_item", "BIKE_VOUCHER", 1, false, + "_PokemonFanClubBagFullText" }, -- 7 { "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8 { "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9 { "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10 @@ -707,6 +704,10 @@ M.MT_MOON_B2F = { end return false end, + -- scripts/MtMoonB2F.asm:8 + noBattles = function(game, ow, x, y) + return x >= 11 and x <= 14 and y >= 5 and y <= 8 and superNerdBeaten(ow) + end, talk = { -- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils -- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold @@ -780,9 +781,14 @@ local function museumClerk(game, ow, done, onDecline) t._Museum1FScientist1ThankYouText or "Right, ¥50!\nThank you!", done, { money = money })) elseif yes then + -- scripts/Museum1F.asm:90-92 game.stack:push(TextBox.new(game, t._Museum1FScientist1DontHaveEnoughMoneyText - or "You don't have\nenough money.", onDecline or done, { money = money })) + or "You don't have\nenough money.", function() + game.stack:push(TextBox.new(game, + t._Museum1FScientist1ComeAgainText, onDecline or done, + { money = money })) + end, { money = money })) else game.stack:push(TextBox.new(game, t._Museum1FScientist1ComeAgainText @@ -1102,14 +1108,14 @@ M.DAYCARE = { game.stack:push(TextBox.new(game, (t._DaycareGentlemanAllRightThenText or "All right then,\n") .. (t._DaycareGentlemanComeAgainText or "come again."), - done)) + done, { money = showMoney })) return end if (game.save.money or 0) < fee then mon.level = startLevel game.stack:push(TextBox.new(game, t._DaycareGentlemanNotEnoughMoneyText - or "Hey, you don't\nhave enough ¥!", done)) + or "Hey, you don't\nhave enough ¥!", done, { money = showMoney })) return end game.save.money = game.save.money - fee @@ -1173,7 +1179,24 @@ M.DAYCARE = { local PartyMenu = require("src.ui.PartyMenu") game.stack:push(PartyMenu.new(game, { pickOnly = true, + -- scripts/Daycare.asm:37 + onCancel = function() + game.stack:push(TextBox.new(game, + t._DaycareGentlemanAllRightThenText, done)) + end, onSwitch = function(mon) + -- scripts/Daycare.asm:39 + local hm = {} + for _, id in ipairs(game.data.constants.hmMoves or {}) do + hm[id] = true + end + for _, mv in ipairs(mon.moves or {}) do + if hm[mv.id] then + game.stack:push(TextBox.new(game, + t._DaycareGentlemanCantAcceptMonWithHMText, done)) + return + end + end for i, m in ipairs(game.save.party) do if m == mon then table.remove(game.save.party, i) break end end diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 3a7d27d7..459528c9 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -14,7 +14,7 @@ local M = {} -- refusedText is the .ThatsSoDisappointingText tail on NO; followText is -- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45) local function rodGiver(askText, receivedText, afterText, rodItem, flag, - refusedText, followText) + refusedText, followText, noRoomText) local rows = { { "face_player" }, { "check_flag", flag }, @@ -24,7 +24,7 @@ local function rodGiver(askText, receivedText, afterText, rodItem, flag, -- give-then-print like the three rod-house scripts (GiveItem fills -- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it) -- scripts/VermilionOldRodHouse.asm:44 - { "give_item", rodItem, 1, false, false, "Get_Item1" }, + { "give_item", rodItem, 1, false, noRoomText, "Get_Item1" }, { "set_flag", flag }, { "show_text", receivedText }, } @@ -46,7 +46,8 @@ M.VERMILION_OLD_ROD_HOUSE = { "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText", "OLD_ROD", "EVENT_GOT_OLD_ROD", "_VermilionOldRodHouseFishingGuruThatsSoDisappointingText", - "_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"), + "_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText", + "_VermilionOldRodHouseFishingGuruNoRoomText"), }, } @@ -57,7 +58,8 @@ M.FUCHSIA_GOOD_ROD_HOUSE = { "_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText", "GOOD_ROD", "EVENT_GOT_GOOD_ROD", - "_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"), + "_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText", nil, + "_FuchsiaGoodRodHouseFishingGuruNoRoomText"), }, } @@ -69,7 +71,8 @@ M.ROUTE_12_SUPER_ROD_HOUSE = { "_Route12SuperRodHouseFishingGuruTryFishingText", "SUPER_ROD", "EVENT_GOT_SUPER_ROD", "_Route12SuperRodHouseFishingGuruThatsDisappointingText", - "_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"), + "_Route12SuperRodHouseFishingGuruFishingWayOfLifeText", + "_Route12SuperRodHouseFishingGuruNoRoomText"), }, } @@ -248,6 +251,7 @@ end local function elevatorFloors(elevatorMapId, game) local floors = {} for mapId, def in pairs(game.data.maps) do + local found for i, w in ipairs(def.warps) do if w.destMap == elevatorMapId then -- short floor token pokered actually prints, e.g. @@ -256,11 +260,11 @@ local function elevatorFloors(elevatorMapId, game) -- warpIdx: this floor's warp back into the elevator IS the -- warp the car's rewritten exit lands on (the reciprocal -- pair), matching wElevatorWarpMaps' (warp id, map id) - table.insert(floors, - { map = mapId, x = w.x, y = w.y, token = token, warpIdx = i }) - break + -- scripts/RocketHideoutElevator.asm:51 + found = { map = mapId, x = w.x, y = w.y, token = token, warpIdx = i } end end + if found then table.insert(floors, found) end end -- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F), -- not lexicographic -- otherwise 10F/11F sort before 2F..9F diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 631365ad..67eb7058 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -481,7 +481,8 @@ local function vendingMachine(game, ow, npc, done) -- engine/events/vending_machine.asm:67-75 if self.done and not paid then paid = true - game.save.money = game.save.money - price + -- engine/math/bcd.asm:208-215 + game.save.money = math.max(0, game.save.money - price) end end end @@ -491,7 +492,8 @@ local function vendingMachine(game, ow, npc, done) result(t._VendingMachineText7 or "Not thirsty!") end local function buy(d) - if game.save.money < d.price then + -- engine/events/vending_machine.asm:39-45 + if game.save.money < 200 then result(t._VendingMachineText4 or "Oops, not enough\nmoney!") return end @@ -659,6 +661,8 @@ M.ROUTE_24 = { talk = { TEXT_ROUTE24_COOLTRAINER_M1 = function(game, ow, npc, done) local flags = game.save.flags + -- scripts/Route24.asm:109 + flags.EVENT_NUGGET_REWARD_AVAILABLE = nil local function battleOrDone() if ow:trainerDefeated(npc) then push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText, @@ -683,6 +687,8 @@ M.ROUTE_24 = { if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, game.data) then push(game, t._Route24CooltrainerM1NoRoomText, done) + -- scripts/Route24.asm:145 + flags.EVENT_NUGGET_REWARD_AVAILABLE = true return end flags.EVENT_GOT_NUGGET = true @@ -702,7 +708,9 @@ M.ROUTE_24 = { end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) return end - battleOrDone() + -- scripts/Route24.asm:138 + push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText, + done) end, }, -- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the @@ -721,11 +729,30 @@ M.ROUTE_24 = { end end if not recruiter then return false end - ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter) + ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter, function() + -- scripts/Route24.asm:36 + local flags = game.save.flags + if not flags.EVENT_NUGGET_REWARD_AVAILABLE then return end + flags.EVENT_NUGGET_REWARD_AVAILABLE = nil + ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) + end) return true end, } +-- scripts/SilphCo1F.asm:1-9 +M.SILPH_CO_1F = { + onEnter = function(game, ow) + local flags = game.save.flags + if not flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return end + if flags.EVENT_SILPH_CO_RECEPTIONIST_AT_DESK then return end + flags.EVENT_SILPH_CO_RECEPTIONIST_AT_DESK = true + require("src.script.Commands").show_object( + { game = game, save = game.save, overworld = ow }, + "SILPH_CO_1F", "SILPHCO1F_LINK_RECEPTIONIST") + end, +} + -- ------------------------------------------------------------------- -- Cinnabar Lab trade room (scripts/CinnabarLabTradeRoom.asm): -- Gramps trades DORIS (Raichu -> Electrode), the Beauty CRINKLES @@ -797,9 +824,10 @@ M.NAME_RATERS_HOUSE = { title = require("src.core.Strings")("NICKNAME?"), maxLen = 10, mon = mon, - default = mon.nickname, onDone = function(name) - if name and #name > 0 and name ~= def.name then + -- engine/menus/naming_screen.asm:66 + if not name or #name == 0 then bye() return end + if name ~= def.name then mon.nickname = name else mon.nickname = nil diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index d06b6f59..494e523d 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -32,6 +32,7 @@ local function gift(opts) say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) return end + if opts.setFlagFirst then game.save.flags[opts.flag] = true end local function give() if not require("src.inventory.Bag").add(game.save, opts.item, 1) then say(opts.noRoom, "You have no room\nfor this item!", done) @@ -153,12 +154,11 @@ M.SILPH_CO_2F = { }, } --- Free POTION sample (scripts/Route1.asm; the original burns the flag --- even on a full bag -- we keep the port's kinder halt-and-retry) +-- scripts/Route1.asm:10-28 M.ROUTE_1 = { talk = { TEXT_ROUTE1_YOUNGSTER1 = gift({ - flag = "EVENT_GOT_POTION_SAMPLE", item = "POTION", + flag = "EVENT_GOT_POTION_SAMPLE", item = "POTION", setFlagFirst = true, pre = "_Route1Youngster1MartSampleText", received = "_Route1Youngster1GotPotionText", noRoom = "_Route1Youngster1NoRoomText", @@ -287,6 +287,8 @@ M.CINNABAR_ISLAND = { -- ready state machine in data/scripts/story2.lua's -- TEXT_CINNABARLABFOSSILROOM_SCIENTIST1. onEnter = function(game, ow) + -- scripts/CinnabarIsland.asm:5 + game.save.flags.EVENT_MANSION_SWITCH_ON = nil if game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL then game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL = nil end @@ -489,6 +491,10 @@ end M.PEWTER_CITY = { escort = pewterEscort, + -- scripts/PewterCity.asm:20 + onEnter = function(game) + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil + end, -- PewterCityYoungsterText: talking also arms the gym escort script talk = { TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done) @@ -572,14 +578,13 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, -- scripts/Route22.asm:132-134, 288-290 { "save_end_battle_text", "_Route22Rival" .. n .. "DefeatedText" }, { "rival_battle", oppClass, baseParty }, - { "jump_if_false", "leave" }, + { "jump_if_false", "end" }, -- scripts/Route22.asm:152 { "set_flag", beatFlag }, { "show_text", "_Route22RivalAfterBattleText" .. n }, { "play_music", "Music_MeetRival", { start = "rival", tempo = n == 2 and 100 or nil } }, { "walk_npc", objIndex, route22ExitDirs(n, py) }, { "play_default_music" }, -- scripts/Route22.asm:230 - { "label", "leave" }, { "hide_object", "ROUTE_22", objName }, } end @@ -662,7 +667,8 @@ local rocketRows = { { "jump_if_false", "end" }, -- 9 { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11 - { "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints) + { "give_item", "TM_DIG", 1, false, + "_CeruleanCityRocketTM28NoRoomText" }, -- 12 (row 14 prints) { "set_flag", "EVENT_GOT_TM28" }, -- 13 { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14 { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15 @@ -853,23 +859,18 @@ local function bikeGateGuard(coords, stopText, explainText) end local dist = y - closestY local t = text(game) + -- scripts/Route16Gate1F.asm:16-80 push(game, t[stopText] or "Hey! Wait up!", function() - push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function() - -- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript - -- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one - -- PAD_RIGHT step after the refusal text, and only clear - -- wJoyIgnore / hand control back once that step finishes - -- (PlayerMovingRightScript). Without it the player was left - -- parked beside the guard's counter with no way past. #518 - local function shoveRight() + local function explain() + push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function() ow:scriptMove(ow.player, "right", 1, nil, { collide = true }) - end - if dist > 0 then - ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true }) - else - shoveRight() - end - end) + end) + end + if dist > 0 then + ow:scriptMove(ow.player, "up", dist, explain, { collide = true }) + else + explain() + end end) return true end @@ -915,13 +916,12 @@ M.SILPH_CO_7F = { -- scripts/SilphCo7F.asm:184-186 { "save_end_battle_text", "_SilphCo7FRivalDefeatedText" }, { "rival_battle", "OPP_RIVAL2", 7 }, - { "jump_if_false", "leave" }, + { "jump_if_false", "end" }, -- scripts/SilphCo7F.asm:209 { "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, { "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, { "play_music", "Music_MeetRival", { start = "rival" } }, { "walk_npc", 9, silphCo7FRivalExitDirs(y) }, { "play_default_music" }, -- scripts/SilphCo7F.asm:261 - { "label", "leave" }, { "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, }, "down") end, @@ -955,13 +955,12 @@ M.SS_ANNE_2F = { -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, { "rival_battle", "OPP_RIVAL2", 1 }, - { "jump_if_false", "leave" }, + { "jump_if_false", "end" }, -- scripts/SSAnne2F.asm:127 { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, { "show_text", "_SSAnne2FRivalCutMasterText" }, { "play_music", "Music_MeetRival", { start = "rival" } }, { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, { "play_default_music" }, -- scripts/SSAnne2F.asm:175 - { "label", "leave" }, { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, } -- the wXCoord == 37 branch (scripts/SSAnne2F.asm:73-77) diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index 4f5030ab..ea9c3d9c 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -230,7 +230,10 @@ M.CINNABAR_GYM = { if not machine then return false end local t = text(game) local Sound = require("src.core.Sound") - push(game, t._CinnabarGymQuizIntroText + -- pokeyellow engine/events/hidden_events/cinnabar_gym_quiz.asm:23 + local intro = index ~= 1 and t._CinnabarGymQuizShortIntroText + or t._CinnabarGymQuizIntroText + push(game, intro or "POKéMON Quiz!\fGet it right and\nthe door opens!", function() ask(game, t["_CinnabarQuizQuestionsText" .. index] or "Well?", function(yes) @@ -254,7 +257,20 @@ M.CINNABAR_GYM = { function() local npc = ow:npcByIndex(machine.npc) if npc and not ow:trainerDefeated(npc) then - ow:engageTrainer(npc, function() end) + -- scripts/CinnabarGym.asm:52-91 + local nerd3 = machine.npc == 4 + local function engage() + ow.player.facing = nerd3 and "down" or "right" + npc:facePlayer(ow.player) + ow:engageTrainer(npc, function() end) + end + ow:scriptMove(npc, "left", 1, function() + if nerd3 then + ow:scriptMove(npc, "up", 1, engage) + else + engage() + end + end) end end) end) diff --git a/data/scripts/yellow_beach_house.lua b/data/scripts/yellow_beach_house.lua index b1642aef..ee8445db 100644 --- a/data/scripts/yellow_beach_house.lua +++ b/data/scripts/yellow_beach_house.lua @@ -49,9 +49,7 @@ return { or (t._SummerBeachHouseSurfinDudeText1 or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?") ow.surfinDudeAsked = true - push(game, ask, function() - local ChoiceBox = require("src.ui.ChoiceBox") - game.stack:push(ChoiceBox.new(game, function(yes) + push(game, ask, nil, { choice = function(yes) if not yes then push(game, t._SummerBeachHouseSurfinDudeText2 or "Come SURF anytime,\nmy friend!", done) @@ -63,8 +61,7 @@ return { require("src.core.Music").playMap(game.data, ow.map.id) done() end)) - end)) - end) + end }) end, TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done) @@ -94,9 +91,7 @@ return { return end push(game, t._SummerBeachHousePrinterText3 - or "The Hi-Score is\nshown.\fPRINT it out?", function() - local ChoiceBox = require("src.ui.ChoiceBox") - game.stack:push(ChoiceBox.new(game, function(yes) + or "The Hi-Score is\nshown.\fPRINT it out?", nil, { choice = function(yes) if not yes then done() return @@ -121,8 +116,7 @@ return { push(game, saved and (Strings("Printed!\f") .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)), done) - end)) - end) + end }) end) end, }, diff --git a/data/scripts/yellow_gifts.lua b/data/scripts/yellow_gifts.lua index 989c00ef..bb9e1613 100644 --- a/data/scripts/yellow_gifts.lua +++ b/data/scripts/yellow_gifts.lua @@ -23,7 +23,7 @@ M.CERULEAN_MELANIES_HOUSE = { rows[#rows + 1] = { "jump_if_false", "declined" } -- engine/events/give_pokemon.asm:45-46 rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10, false, true } - rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full + rows[#rows + 1] = { "jump_if_false", "box_full" } -- Event and HideObject before MelanieText3: give_pokemon's nickname -- prompt and the received text both yield, and a script that dies -- in that window used to leave the BULBASAUR given with the event @@ -36,6 +36,10 @@ M.CERULEAN_MELANIES_HOUSE = { rows[#rows + 1] = { "jump", "end" } rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "show_text", "MelanieText5" } + rows[#rows + 1] = { "jump", "end" } + -- engine/events/give_pokemon.asm:40-44 + rows[#rows + 1] = { "label", "box_full" } + rows[#rows + 1] = { "show_text", "_BoxIsFullText" } end ow.runner:run(rows, { npc = npc, onDone = done, checkpointOnDone = "release_npc" }) @@ -67,7 +71,7 @@ M.ROUTE_24 = { { "jump_if_false", "declined" }, -- engine/events/give_pokemon.asm:45-46 { "give_pokemon", "CHARMANDER", 10, false, true }, - { "jump_if_false", "end" }, + { "jump_if_false", "box_full" }, -- SetEvent hoisted ahead of the received text (Route24.asm writes it -- after Route24Text_515e3, where nothing in between can fail): the -- nickname prompt and the text both yield, and a script death in that @@ -79,6 +83,10 @@ M.ROUTE_24 = { { "label", "declined" }, { "show_text", "_Route24DamianText3" }, { "jump", "end" }, + -- engine/events/give_pokemon.asm:40-44 + { "label", "box_full" }, + { "show_text", "_BoxIsFullText" }, + { "jump", "end" }, { "label", "after" }, { "show_text", "_Route24DamianText4" }, }, @@ -100,7 +108,7 @@ M.VERMILION_CITY = { rows[#rows + 1] = { "jump_if_false", "declined" } -- engine/events/give_pokemon.asm:45-46 rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10, false, true } - rows[#rows + 1] = { "jump_if_false", "end" } + rows[#rows + 1] = { "jump_if_false", "box_full" } -- event before the received text, as above (#426) rows[#rows + 1] = { "set_flag", "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" } @@ -108,6 +116,10 @@ M.VERMILION_CITY = { rows[#rows + 1] = { "jump", "end" } rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "show_text", "_OfficerJennyText4" } + rows[#rows + 1] = { "jump", "end" } + -- engine/events/give_pokemon.asm:40-44 + rows[#rows + 1] = { "label", "box_full" } + rows[#rows + 1] = { "show_text", "_BoxIsFullText" } end ow.runner:run(rows, { npc = npc, onDone = done, checkpointOnDone = "release_npc" }) diff --git a/data/scripts/yellow_jessie_james.lua b/data/scripts/yellow_jessie_james.lua index 558948e0..0bd37f88 100644 --- a/data/scripts/yellow_jessie_james.lua +++ b/data/scripts/yellow_jessie_james.lua @@ -114,7 +114,7 @@ M.MT_MOON_B2F = { { "save_end_battle_text", "_MtMoonJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 42 }, { "check_battle_result", "win" }, - { "jump_if_false", "end" }, + { "jump_if_false", "lost" }, partingRows("_MtMoonJessieJamesText4", 2, 6), { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -124,6 +124,11 @@ M.MT_MOON_B2F = { { "fade", "in" }, { "play_default_music" }, { "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, + { "jump", "end" }, + -- pokeyellow scripts/MtMoonB2F.asm:40 + { "label", "lost" }, + { "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" }, + { "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" }, }), {}) return true end, diff --git a/data/scripts/yellow_npcs.lua b/data/scripts/yellow_npcs.lua new file mode 100644 index 00000000..b09f8df4 --- /dev/null +++ b/data/scripts/yellow_npcs.lua @@ -0,0 +1,230 @@ +local M = {} + +local function push(game, text, done, opts) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, text, done, opts)) +end + +local function happiness(game) + return game.save.pikachuHappiness or 90 +end + +-- home/map_objects.asm:114 +local function starterAlive(game) + local Follower = require("src.world.PikachuFollower") + for _, mon in ipairs(game.save.party or {}) do + if Follower.isStarterPikachu(game.save, mon) and (mon.hp or 0) > 0 then + return true + end + end + return false +end + +local function run(ow, rows, npc, done) + ow.runner:run(rows, { npc = npc, onDone = done }) +end + +local function ownedCount(game) + local owned = 0 + local dex = game.save.pokedex + for _ in pairs(dex and dex.owned or {}) do owned = owned + 1 end + return owned +end + +local sharedChairman = require("data.scripts.story2") + .POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CHAIRMAN + +M.PEWTER_POKECENTER = { + talk = { + -- scripts/PewterPokecenter_2.asm:1 + TEXT_PEWTERPOKECENTER_COOLTRAINER_F = { + { "face_player" }, + { "show_text", "_PewterPokecenterText3" }, + }, + }, +} + +-- scripts/CeladonMansion1F_2.asm:47 +local GRANNY_BANDS = { + { 51, "_CeladonMansion1Text7" }, { 101, "_CeladonMansion1Text8" }, + { 131, "_CeladonMansion1Text9" }, { 161, "_CeladonMansion1Text10" }, + { 201, "_CeladonMansion1Text11" }, { 255, "_CeladonMansion1Text12" }, +} + +M.CELADON_MANSION_1F = { + talk = { + -- scripts/CeladonMansion1F.asm:20 + TEXT_CELADONMANSION1F_GRANNY = function(game, ow, npc, done) + local rows = { { "face_player" }, + { "show_text", "_CeladonMansion1Text2" } } + local cry = false + if starterAlive(game) then + local h = happiness(game) + local band = "_CeladonMansion1Text12" + for _, b in ipairs(GRANNY_BANDS) do + if h < b[1] then band = b[2] break end + end + rows[#rows + 1] = { "show_text", "_CeladonMansion1Text6" } + rows[#rows + 1] = { "show_text", band } + end + -- scripts/CeladonMansion1F.asm:24 + if happiness(game) >= 251 then + cry = true + rows[#rows + 1] = { "wait", 50 } + end + run(ow, rows, npc, function() + if cry then + require("src.core.Sound").playCry(game.data, "PIKACHU", 23) + end + if done then done() end + end) + end, + }, +} + +M.MUSEUM_2F = { + talk = { + -- scripts/Museum2F.asm:31 + TEXT_MUSEUM2F_HIKER = function(game, ow, npc, done) + local key = "_Museum2FHikerText" + if starterAlive(game) then + key = happiness(game) >= 101 and "_Museum2FPikachuText2" + or "_Museum2FPikachuText1" + end + run(ow, { { "face_player" }, { "show_text", key } }, npc, done) + end, + }, +} + +M.PEWTER_GYM = { + talk = { + -- scripts/PewterGym.asm:181 + TEXT_PEWTERGYM_GYM_GUIDE = function(game, ow, npc, done) + local rows = { { "face_player" } } + if game.save.flags.EVENT_BEAT_BROCK then + rows[#rows + 1] = { "show_text", "_PewterGymGuidePostBattleText" } + return run(ow, rows, npc, done) + end + rows[#rows + 1] = { "ask", "_PewterGymGuidePreAdviceText" } + rows[#rows + 1] = { "jump_if_false", "free" } + if starterAlive(game) then + -- scripts/PewterGym.asm:193 + rows[#rows + 1] = { "show_text", "_PewterGymGuyText" } + rows[#rows + 1] = { "jump", "end" } + else + rows[#rows + 1] = { "show_text", "_PewterGymGuideBeginAdviceText" } + rows[#rows + 1] = { "jump", "advice" } + end + rows[#rows + 1] = { "label", "free" } + rows[#rows + 1] = { "show_text", "_PewterGymGuideFreeServiceText" } + rows[#rows + 1] = { "label", "advice" } + rows[#rows + 1] = { "show_text", "_PewterGymGuideAdviceText" } + rows[#rows + 1] = { "label", "end" } + run(ow, rows, npc, done) + end, + }, +} + +M.CELADON_MANSION_3F = { + talk = { + -- scripts/CeladonMansion3F.asm:23 + TEXT_CELADONMANSION3F_PROGRAMMER = function(game, ow, npc, done) + local key = ownedCount(game) >= 150 and "_CeladonMansion3FProgrammerText2" + or "_CeladonMansion3FProgrammerText" + run(ow, { { "face_player" }, { "show_text", key } }, npc, done) + end, + -- scripts/CeladonMansion3F.asm:104 + TEXT_CELADONMANSION3F_WRITER = function(game, ow, npc, done) + local key = ownedCount(game) >= 150 and "_CeladonMansion3FWriterText2" + or "_CeladonMansion3FWriterText" + run(ow, { { "face_player" }, { "show_text", key } }, npc, done) + end, + }, +} + +local function fanText(yellowKey, ownBoast, otherBoast, normalKey, betterKey) + return function(game, ow, npc, done) + local flags = game.save.flags + local rows = { { "face_player" } } + if flags.EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER then + rows[#rows + 1] = { "show_text", yellowKey } + elseif flags[ownBoast] then + rows[#rows + 1] = { "clear_flag", ownBoast } + rows[#rows + 1] = { "show_text", betterKey } + else + rows[#rows + 1] = { "set_flag", otherBoast } + rows[#rows + 1] = { "show_text", normalKey } + end + run(ow, rows, npc, done) + end +end + +local function printPortrait(game, mon) + local Printer = require("src.core.Printer") + local Font = require("src.render.Font") + local Strings = require("src.core.Strings") + local def = game.data.pokemon[mon.species] or {} + local ok, img = pcall(love.graphics.newImage, def.spriteFront or "") + local saved, err = Printer.save("fanclub_portrait", 160, 144, function() + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", 4.5, 4.5, 151, 135) + love.graphics.setColor(1, 1, 1, 1) + if ok and img then + local w, h = img:getDimensions() + love.graphics.draw(img, math.floor(80 - w / 2), math.floor(64 - h / 2)) + end + Font.draw(mon.nickname or def.name or mon.species, 16, 112) + end) + return saved and (Strings("Printed!\f") .. Printer.savedWhereText(saved)) + or Strings("Printer error!\n%s", tostring(err)) +end + +M.POKEMON_FAN_CLUB = { + talk = { + -- scripts/PokemonFanClub.asm:81 + TEXT_POKEMONFANCLUB_CLEFAIRY_FAN = fanText("_PokemonFanClubClefairyFanText", + "EVENT_PIKACHU_FAN_BOAST", "EVENT_SEEL_FAN_BOAST", + "_PokemonFanClubClefairyFanNormalText", "_PokemonFanClubClefairyFanBetterText"), + -- scripts/PokemonFanClub.asm:115 + TEXT_POKEMONFANCLUB_SEEL_FAN = fanText("_PokemonFanClubSeelFanText", + "EVENT_SEEL_FAN_BOAST", "EVENT_PIKACHU_FAN_BOAST", + "_PokemonFanClubSeelFanNormalText", "_PokemonFanClubSeelFanBetterText"), + -- scripts/PokemonFanClub.asm:175 + TEXT_POKEMONFANCLUB_CHAIRMAN = function(game, ow, npc, done) + if not game.save.flags.EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER then + return run(ow, sharedChairman, npc, done) + end + local t = game.data.text + if npc and npc.facePlayer then npc:facePlayer(ow.player) end + local function finish(key) + push(game, t[key], done) + end + push(game, t.FanClubChairPrintText1, nil, { choice = function(yes) + if not yes then return finish("FanClubChairPrintText2") end + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game, { + pickOnly = true, + onCancel = function() finish("FanClubChairPrintText2") end, + onSwitch = function(mon) + push(game, printPortrait(game, mon), function() + finish("FanClubChairPrintText3") + end) + end, + })) + end }) + end, + }, + -- engine/events/hidden_events/fanclub_pictures.asm:1 + onInteract = function(game, ow, fx, fy) + if fy ~= 0 or (fx ~= 1 and fx ~= 6) then return false end + local species = fx == 1 and "RAPIDASH" or "FEAROW" + local key = fx == 1 and "_FanClubPicture1Text" or "_FanClubPicture2Text" + local def = game.data.pokemon[species] + local PicBox = require("src.ui.PicBox") + game.stack:push(PicBox.new(game, def and def.spriteFront or "", + game.data.text[key])) + return true + end, +} + +return M diff --git a/data/scripts/yellow_viridian_old_man.lua b/data/scripts/yellow_viridian_old_man.lua index 3465c334..f0d0d3d2 100644 --- a/data/scripts/yellow_viridian_old_man.lua +++ b/data/scripts/yellow_viridian_old_man.lua @@ -85,7 +85,9 @@ local function oldMan2Rows(game, ow, npc) if pika then rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } } end - rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } } + -- scripts/ViridianCity.asm:234 + rows[#rows + 1] = { "walk_npc", npc.def.index, + { "right", "down", "down", "down", "down", "down", "down" } } end rows[#rows + 1] = { "hide_object", "VIRIDIAN_CITY", OLD_MAN2 } diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 0acd9f08..8137ab83 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -84,7 +84,7 @@ local SE_FRAMES = { SE_DARKEN_MON_PALETTE = 0, SE_RESET_SCREEN_PALETTE = 0, SE_SHAKE_SCREEN = 72, -- PredefShakeScreenHorizontally b=8: sum 9f - SE_SHAKE_ENEMY_HUD = 44, -- 8 x (2f + 2f) SCX shake + setup Delay3s + SE_SHAKE_ENEMY_HUD = 60, -- engine/battle/animations.asm:2472 SE_DELAY_ANIMATION_10 = 10, SE_SLIDE_MON_OFF = 24, -- 8 tile steps x 3f (wSlideMonDelay) SE_SLIDE_ENEMY_MON_OFF = 24, -- same routine, turn flipped @@ -100,7 +100,7 @@ local SE_FRAMES = { SE_MINIMIZE_MON = 6, SE_SHOW_MON_PIC = 3, SE_SHOW_ENEMY_MON_PIC = 3, SE_HIDE_MON_PIC = 3, SE_HIDE_ENEMY_MON_PIC = 3, - SE_BLINK_MON = 60, SE_BLINK_ENEMY_MON = 60, -- 6 x (5f off + 5f on) + SE_BLINK_MON = 78, SE_BLINK_ENEMY_MON = 78, -- engine/battle/animations.asm:1360 SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4, SE_TRANSFORM_MON = 4, SE_SUBSTITUTE_MON = 3, @@ -336,8 +336,10 @@ end -- effect id -> compiled sprite steps local EMITTERS = { - SE_SPIRAL_BALLS_INWARD = function(isPlayer) return spiralBallSteps(isPlayer), "flash" end, - SE_SHOOT_BALLS_UPWARD = function(isPlayer) return shootBallsSteps(isPlayer) end, + -- engine/battle/animations.asm:1499 + SE_SPIRAL_BALLS_INWARD = function(isPlayer) return spiralBallSteps(isPlayer), "flash", 0 end, + -- engine/battle/animations.asm:1640 + SE_SHOOT_BALLS_UPWARD = function(isPlayer) return shootBallsSteps(isPlayer), nil, 0 end, SE_SHOOT_MANY_BALLS_UPWARD = function(isPlayer) return shootManyBallsSteps(isPlayer) end, SE_WATER_DROPLETS_EVERYWHERE = function() return waterDropletSteps(), nil, 0, true @@ -345,10 +347,10 @@ local EMITTERS = { -- AnimationLeavesFalling runs under wAnimPalette ($f0 on SGB); -- petals keep the ambient $e4 SE_LEAVES_FALLING = function(_, data) - return fallingObjectSteps(3, LEAF_TILE, "f0", data.fallingDeltaXs) + return fallingObjectSteps(3, LEAF_TILE, "f0", data.fallingDeltaXs), nil, 1 end, SE_PETALS_FALLING = function(_, data) - return fallingObjectSteps(20, PETAL_TILE, "e4", data.fallingDeltaXs) + return fallingObjectSteps(20, PETAL_TILE, "e4", data.fallingDeltaXs), nil, 1 end, } @@ -449,6 +451,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) end local idFx = ANIM_ID_FX[moveId] + -- pokeyellow data/battle_anims/special_effects.asm:21 + if moveId == "SPORE" and require("src.core.GameVersion").isYellow() then + idFx = "every4" + end -- DoBallTossSpecialEffects (:685): while a Master or Ultra ball is -- tossed (wCurItem <= ULTRA_BALL), the per-block special effect XORs diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua index 2ea4d97b..dfb8da32 100644 --- a/src/battle/BattleAPI.lua +++ b/src/battle/BattleAPI.lua @@ -118,7 +118,7 @@ local function moveCopies(game, battle) out[slot] = { slot = slot, id = move.id, name = def.name or move.id, pp = move.pp, maxPp = (def.pp or move.pp or 0) - + (move.ppUps or 0) * math.floor((def.pp or 0) / 5), + + (move.ppUps or 0) * require("src.pokemon.Pokemon").ppUpBonus(def.pp), type = def.type, power = def.power, accuracy = def.accuracy, displayPower = displayPower, hitChance = hitChance, effectiveness = mult, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6d091d50..bee67fa0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -244,6 +244,8 @@ local imagePadBottom = setmetatable({}, WEAK_KEYS) -- this is subtracted from hlcoord 1,5 so opaque pixels match hardware, -- where those columns were white-on-white rather than shifted content local imagePadLeft = setmetatable({}, WEAK_KEYS) +local imageBackCrop = setmetatable({}, WEAK_KEYS) +local BACK_PIC_SIZE, BACK_PIC_CROP = 32, 28 -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = setmetatable({}, WEAK_KEYS) @@ -272,7 +274,7 @@ local function getImage(path, pal, trueColor) end local key = pal and (path .. "#" .. pal.name) or path if not imageCache[key] then - local img, pad, padL = nil, 0, 0 + local img, pad, padL, backCrop = nil, 0, 0, false if love.image and love.image.newImageData then local id = Assets.imageData(path) if pal then @@ -308,6 +310,10 @@ local function getImage(path, pal, trueColor) end pad = h - 1 - bottom padL = left + -- engine/battle/scale_sprites.asm:1-3 + if w == BACK_PIC_SIZE and h == BACK_PIC_SIZE and path:find("/battle/back/", 1, true) then + pad, padL, backCrop = BACK_PIC_SIZE - BACK_PIC_CROP, 0, true + end end img = love.graphics.newImage(id) else @@ -316,6 +322,7 @@ local function getImage(path, pal, trueColor) imageCache[key] = img imagePadBottom[img] = pad imagePadLeft[img] = padL + imageBackCrop[img] = backCrop or nil imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } end return imageCache[key] @@ -524,7 +531,9 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays() - if self.grayPics or mono then return grayImage(img) end + if self.grayPics then return grayImage(img) end + -- engine/battle/animations.asm:1097 + if mono then return fadeImage(grayImage(img), self:activeBgp()) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the @@ -903,6 +912,24 @@ local function setThirdMove(data, mon, moveId) end local function applySpecialMoves(data, oppClass, partyIndex, party) + -- pokeyellow engine/battle/read_trainer_party.asm:87 + if require("src.core.GameVersion").isYellow() then + local trainer = data.trainers and data.trainers[oppClass] + for _, entry in ipairs(trainer and trainer.specialMoves or {}) do + if entry.party == partyIndex then + for _, row in ipairs(entry.moves) do + local mon = party[row.mon] + if mon then + local mdef = data.moves[row.move] + mon.moves[math.min(row.slot, #mon.moves + 1)] = + { id = row.move, pp = mdef and mdef.pp or 0 } + end + end + return + end + end + return + end local lone = LONE_MOVES[oppClass] if lone and (not lone.onlyParty or lone.onlyParty == partyIndex) then setThirdMove(data, party[lone[1]], lone[2]) @@ -1827,9 +1854,6 @@ end -- asleep (IsPlayerPikachuAsleepInParty); PrintBeginningBattleText does the -- same for the BATTLE_TYPE_PIKACHU intro (engine/battle/common_text.asm: -- 12-19). Without a clip the bare playCry reached for clip 1, the long --- title-screen "Pikachuuu" (#837). Every PIKACHU gets it here, the same --- starter approximation the rest of the port makes --- (PikachuFollower.starterInParty). function BattleState:playEntranceCry(battler) local mon = battler and battler.mon if not mon then return end @@ -1939,10 +1963,6 @@ function BattleState:enter() -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs -- playerMonFainted queues on the battle screen; there is no battle -- screen to queue them on here, so they print over the map. - -- _PlayerBlackedOutText (no "2") extracts to the identical wording from - -- a different ROM address and is unused anywhere in this engine -- not - -- a fallback for this one, just pokered printing the same paragraph - -- from a second call site elsewhere. self.game.stack:push(require("src.render.TextBox").new(self.game, self:romText("_PlayerBlackedOutText2", "%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name), @@ -2295,24 +2315,21 @@ function BattleState:chooseMenu(choice) return nil, "battle menu is not ready" end self:clearTurnFlinches() - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - -- A scared turn still ticks the player's residual effects. - self:queueResidual(self.player, self.enemy) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then + if choice == "fight" then -- Trapping, Bide, and similar locks skip the move list. local fightLock = self:fightLockedAction(self.player) + local status = self.player.mon.status if fightLock then self:resolveTurn(fightLock) + -- engine/battle/core.asm:310 + elseif status == "SLP" or status == "FRZ" then + local moves = self.player.curMoves + self:resolveTurn(moves[self.playerMoveListIndex or 1] or moves[1]) elseif not self:playerHasPP() then -- No usable PP goes straight to Struggle. - self:say(Strings("%s has no\nmoves left!", self.player.name)) + -- engine/battle/core.asm:2751-2754 + self:sayAuto(self:romText("_NoMovesLeftText", "%s has no\nmoves left!", + self.player.name), 60) self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true }) else self.phase = "moveSelect" @@ -2339,11 +2356,12 @@ function BattleState:chooseMove(index) if self.player.disabledSlot == index then self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) self.phase = "messages" - self.afterQueue = "menu" + -- engine/battle/core.asm:2679 + self.afterQueue = "moveSelect" elseif move.pp <= 0 then self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) self.phase = "messages" - self.afterQueue = "menu" + self.afterQueue = "moveSelect" else self.playerMoveListIndex = index self:resolveTurn(move) @@ -2493,6 +2511,10 @@ function BattleState:update(dt) self.msgHold = nil self.shown = nil self:enterCommandMenu() + elseif destination == "moveSelect" then + self.msgHold = nil + self.shown = nil + self.phase = "moveSelect" elseif destination == "finish" then self:finish() end @@ -2514,7 +2536,9 @@ function BattleState:update(dt) if self.phase == "menu" and self.safari then if self.safari.balls <= 0 then - self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!")) + -- engine/battle/core.asm:174-179 + self:say(romText(self.data, "_OutOfSafariBallsText", + "PA: Ding-dong!\fYou are out of\nSAFARI BALLs!")) self.phase = "messages" self.result = "run" self.afterQueue = "finish" @@ -3397,7 +3421,6 @@ end -- charge hide is a cleared tilemap that must survive until SE_SHOW_* / -- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release): -- clearing it here made Dig pop in before emerge and wrap/bounce (#100). --- Other hides (Acid Armor, etc.) still clear so the next anim restores. function BattleState:resetPicFx() if not self.picFx then return end local digFly = self.animName == "DIG" or self.animName == "FLY" @@ -3407,6 +3430,7 @@ function BattleState:resetPicFx() pf.kind, pf.t = nil, nil pf.ox, pf.oy = 0, 0 local keepHide = battler.invulnerable or battler == digFlyUser + or pf.persistHide if not keepHide then pf.hidden = nil end @@ -3449,18 +3473,15 @@ end local function startPicKind(pf, kind) if not pf then return end pf.kind, pf.t = kind, 0 - pf.hidden = nil + pf.hidden, pf.persistHide = nil, nil end --- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window --- jumps right by b for 5 frames then home for 4, b counting down to 1. --- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for --- the light one. +-- engine/gfx/screen_effects.asm:40 local function fastShakeProg(b) - local prog = {} - for i = b, 1, -1 do - prog[#prog + 1] = { dx = i, frames = 5 } - prog[#prog + 1] = { dx = 0, frames = 4 } + local prog = { { dx = b, frames = 5 }, { dx = 0, frames = 4 } } + for i = b - 1, 1, -1 do + prog[#prog + 1] = { dx = 0, frames = 5 } + prog[#prog + 1] = { dx = i, frames = 4 } end return prog end @@ -3528,10 +3549,8 @@ function BattleState:applyAnimEffect(ev) fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 }, { dy = 1, frames = 3 }, { dy = 0, frames = 3 } } elseif e == "SE_SHAKE_ENEMY_HUD" then - -- AnimationShakeEnemyHUD: SCX +-2 for 2 frames each, 8 times; the - -- window + a sprite copy of the back pic keep everything below the - -- enemy HUD still, so only the HUD area moves - local prog = {} + -- engine/battle/animations.asm:2472 + local prog = { { dx = 0, frames = 20 } } for _ = 1, 8 do prog[#prog + 1] = { dx = 2, frames = 2 } prog[#prog + 1] = { dx = -2, frames = 2 } @@ -3554,7 +3573,10 @@ function BattleState:applyAnimEffect(ev) elseif e == "SE_SLIDE_MON_DOWN" then startPicKind(self:picFxFor(self:animFxBattler(false)), "slideDown") elseif e == "SE_SLIDE_MON_DOWN_AND_HIDE" then - startPicKind(self:picFxFor(self:animFxBattler(false)), "slideDownHide") + local pf = self:picFxFor(self:animFxBattler(false)) + startPicKind(pf, "slideDownHide") + -- engine/battle/animations.asm:1757-1777 + if pf then pf.persistHide = true end elseif e == "SE_SHAKE_BACK_AND_FORTH" then startPicKind(self:picFxFor(self:animFxBattler(false)), "shakeBF") elseif e == "SE_BOUNCE_UP_AND_DOWN" then @@ -3571,7 +3593,7 @@ function BattleState:applyAnimEffect(ev) local b = self:animFxBattler(false) local pf = self:picFxFor(b) if pf then - pf.kind, pf.hidden = nil, nil + pf.kind, pf.hidden, pf.persistHide = nil, nil, nil pf.ox = b.isPlayer and 8 or -8 pf.oy = 0 end @@ -3582,10 +3604,10 @@ function BattleState:applyAnimEffect(ev) end elseif e == "SE_SHOW_MON_PIC" then local pf = self:picFxFor(self:animFxBattler(false)) - if pf then pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0 end + if pf then pf.kind, pf.hidden, pf.persistHide, pf.ox, pf.oy = nil, nil, nil, 0, 0 end elseif e == "SE_SHOW_ENEMY_MON_PIC" then local pf = self:picFxFor(self:animFxBattler(true)) - if pf then pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0 end + if pf then pf.kind, pf.hidden, pf.persistHide, pf.ox, pf.oy = nil, nil, nil, 0, 0 end elseif e == "SE_HIDE_MON_PIC" or e == "SE_HIDE_ATTACKER_PIC" then local pf = self:picFxFor(self:animFxBattler(false)) if pf then pf.kind, pf.hidden = nil, true end @@ -3602,7 +3624,7 @@ function BattleState:applyAnimEffect(ev) elseif e == "SE_FLASH_MON_PIC" or e == "SE_FLASH_ENEMY_MON_PIC" then -- ChangeMonPic reloads the mon's own pic (clears a minimize) local pf = self:picFxFor(self:animFxBattler(e == "SE_FLASH_ENEMY_MON_PIC")) - if pf then pf.kind, pf.hidden, pf.minimized = nil, nil, nil end + if pf then pf.kind, pf.hidden, pf.minimized, pf.persistHide = nil, nil, nil, nil end elseif e == "SE_TRANSFORM_MON" then -- AnimationTransformMon redraws the user as the opposing species local user = self:animFxBattler(false) @@ -3998,7 +4020,7 @@ function BattleState:updateFx() pf.kind = nil -- AnimationShowMonPic after the last bounce elseif k == "squish" and t >= 24 then pf.kind, pf.hidden = nil, true - elseif k == "blink" and t >= 60 then + elseif k == "blink" and t >= 78 then pf.kind = nil -- ends shown end end @@ -4122,6 +4144,12 @@ function BattleState:executeAction(user, target, action) self:sayNext(self.data.text._GetOutText or Strings("GHOST: Get out...\nGet out...")) return end + -- engine/battle/core.asm:3089 + if self.ghost and user.isPlayer and action.special ~= "bound" + and user.mon.status ~= "SLP" and user.mon.status ~= "FRZ" then + self:sayNext(self:romText("_ScaredText", "%s is too\nscared to move!", displayName(user))) + return + end -- refresh the held-in-place mirror before the status checks (see -- lockedAction): the victim is held exactly while the opponent's @@ -4563,7 +4591,9 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the -- never-miss paths (X ACCURACY) live inside Damage.accuracyRoll - if record.accuracyChecked + -- engine/battle/effects.asm:44 + local alreadyAsleep = move.effect == "SLEEP_EFFECT" and target.mon.status == "SLP" + if record.accuracyChecked and not alreadyAsleep and (target.invulnerable or not self:accuracyRoll(move, user, target)) then -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only @@ -4580,10 +4610,30 @@ function BattleState:performMove(user, target, moveInst, isCalled) elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 } end - for _, m in ipairs(msgs) do - self:sayNext(m) + local first = 1 + -- engine/battle/move_effects/heal.asm:42 + if msgs.restLead and self.moveAnimRow then + local row = self.moveAnimRow + for i, item in ipairs(self.queue) do + if item == row then + table.remove(self.queue, i) + if self.nextInsert and i <= self.nextInsert then + self.nextInsert = self.nextInsert - 1 + end + break + end + end + self:sayNext(msgs[1]) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, row) + first = 2 end - self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar + -- engine/battle/move_effects/heal.asm:99 + if msgs.hpBarFirst then self:drainNext() end + for i = first, #msgs do + self:sayNext(msgs[i]) + end + if not msgs.hpBarFirst then self:drainNext() end return end if move.power == 0 and not (record and record.kind == "full") then @@ -4598,7 +4648,8 @@ function BattleState:performMove(user, target, moveInst, isCalled) end function BattleState:continueTrapping(user, target) - self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) + -- engine/battle/core.asm:3642 + self:sayNextAuto(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText -- then jumps to GetPlayerAnimationType, so the trapping move's full -- animation replays each locked turn (same damage, animation shown). @@ -4629,9 +4680,10 @@ function BattleState:continueBide(user, target) self:sayNext(self:romText("_UnleashedEnergyText", "%s\nunleashed energy!", displayName(user))) local dmg = (user.bideDamage or 0) * 2 user.bideTurns, user.bideDamage = nil, nil + -- engine/battle/core.asm:3521 if dmg <= 0 then self:cancelMoveAnim() - self:sayNext(self:romText("_ButItFailedText", "But, it failed!")) + self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user))) return end -- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE @@ -4654,11 +4706,12 @@ function BattleState:applyDamage(target, dmg) if target.substituteHP then target.substitutePending = nil target.substituteHP = target.substituteHP - dmg + -- engine/battle/core.asm:4857 + self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) if target.substituteHP <= 0 then target.substituteHP = nil + -- engine/battle/core.asm:4884 self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target))) - else - self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) end return dmg end @@ -5148,7 +5201,8 @@ function BattleState:learnMove(mon, moveId) require("src.world.PikachuFollower") .onMoveLearned(self.game.save, mon, moveId) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, + -- engine/pokemon/learn_move.asm:93 + self:sayNextWaitSfx(self:romText("_LearnedMove1Text", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, mdef.name), function() return require("src.core.Sound").play(self.data, "Level_Up") end) @@ -5363,16 +5417,21 @@ function BattleState:safariAction(choice) end if choice == "bait" then - self:say(self:romText("_ThrewBaitText", "%s threw some\nBAIT.", playerName)) + -- engine/items/item_effects.asm:1434 + self:sayAuto(self:romText("_ThrewBaitText", "%s threw some\nBAIT.", playerName)) self.safariCatchRate = math.floor(self.safariCatchRate / 2) self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5)) self.escapeFactor = 0 else -- rock - self:say(self:romText("_ThrewRockText", "%s threw a\nROCK.", playerName)) + self:sayAuto(self:romText("_ThrewRockText", "%s threw a\nROCK.", playerName)) self.safariCatchRate = math.min(255, self.safariCatchRate * 2) self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5)) self.baitFactor = 0 end + -- engine/items/item_effects.asm:1479 + table.insert(self.queue, { anim = choice == "bait" and "BAIT_ANIM" or "ROCK_ANIM", + attackerIsPlayer = true }) + table.insert(self.queue, { wait = 70 }) self:act(function() self:safariEnemyTurn() end) end @@ -5512,6 +5571,9 @@ function BattleState:itemUsed(messages, opts) end end end + if opts and opts.anim then + table.insert(self.queue, { anim = opts.anim, attackerIsPlayer = true }) + end for _, m in ipairs(messages or {}) do self:say(m) end if not barShown then table.insert(self.queue, { drain = true }) @@ -5957,6 +6019,23 @@ function BattleState:finish() self:restoreMimicked(self.enemy) -- end_of_battle.asm clears wLowHealthAlarm at battle teardown require("src.core.Sound").stopLoop("Low_Health_Alarm") + if self.result == "lose" and self.kind ~= "link" and not self.demo + and not BattleState.isOaksLabStarterRival(self) then + local game, onFinish, Music = self.game, self.onFinish, require("src.core.Music") + -- home/overworld.asm:759 + game.stack:push(require("src.render.Transition").new(game, function() + Music.restoreMap(self.data) + game.stack:pop() + Runtime.emit("battle.ended", { battle = self, result = "lose" }) + game.blackoutFaded = true + if onFinish then onFinish("lose") end + game.blackoutFaded = nil + end, nil, false, { frames = require("src.core.Timing").FADE_OUT_TO_BLACK, + framesIn = 0, hold = 110 })) + -- home/overworld.asm:760 + Music.fadeOut(8) + return + end -- the victory theme already started when the win was decided -- (FaintEnemyPokemon .wild_win / TrainerBattleVictory) and loops until -- the battle screen closes; leaving battle brings back the map theme, @@ -6107,12 +6186,11 @@ end -- battler hidden this frame? (damage blink) -- -- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits --- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame --- period, not 8. With Timing.BLINK_MON that is exactly six blinks. +-- engine/battle/animations.asm:1360 function BattleState:fxHidden(battler) local fx = self.fx if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then - return self.frame % 10 < 5 + return (Timing.BLINK_MON - fx.blink.frames) % 13 < 5 end return false end @@ -6309,7 +6387,13 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY) local pf = self.picFx and self.picFx[battler] if not pf or (not pf.kind and not pf.hidden and not pf.minimized and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then - love.graphics.draw(img, x, y, 0, scale, scale) + if battler.isPlayer and imageBackCrop[img] then + local quad = self:picQuad(battler, 0, 0, BACK_PIC_CROP, BACK_PIC_CROP, + img:getWidth(), img:getHeight()) + love.graphics.draw(img, quad, x, y, 0, scale, scale) + else + love.graphics.draw(img, x, y, 0, scale, scale) + end return end if pf.hidden then return end @@ -6364,7 +6448,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY) end local skipDraw = (k == "squish" and xscale <= 0) - or (k == "blink" and math.floor(t / 5) % 2 == 0) + or (k == "blink" and t % 13 < 5) if skipDraw then -- draw nothing this frame, but still restore the scissor rect @@ -6628,10 +6712,12 @@ function BattleState:animSpriteColors(s, px, py, memo) local key = s.obp or "f0" local P -- engine/battle/animations.asm:551 (.notSGB) + if PaletteFX.runsNotSGB() then + if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end + end if PaletteFX.usesSpriteObp() then -- engine/battle/init_battle_variables.asm:18 P = PaletteFX.ogObjBase() - if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end elseif memo then local pals = memo.pals if pals == nil then @@ -6816,7 +6902,8 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) else local dx, dy = BattleState.frontPlacement(ex, ey, img:getWidth(), img:getHeight(), s) - self:drawBattlerPic(self.enemy, dx, dy, s, sx - slide, sy) + self:drawBattlerPic(self.enemy, dx, dy, s, + sx - slide + ((self.fx and self.fx.hudShakeX) or 0), sy) end end @@ -6927,8 +7014,13 @@ function BattleState:drawHUDs(slide) if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then - hudTile(0x6E, 32, 8) -- - Font.draw(tostring(self.enemy.mon.level), 40, 8) + -- home/pokemon.asm:335-345 + if self.enemy.mon.level < 100 then + hudTile(0x6E, 32, 8) -- + Font.draw(tostring(self.enemy.mon.level), 40, 8) + else + Font.draw(tostring(self.enemy.mon.level), 32, 8) + end end hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, @@ -7011,8 +7103,13 @@ function BattleState:drawHUDs(slide) if self.player.shownStatus then Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then - hudTile(0x6E, 112, 64) -- - Font.draw(tostring(self.player.mon.level), 120, 64) + -- home/pokemon.asm:335-345 + if self.player.mon.level < 100 then + hudTile(0x6E, 112, 64) -- + Font.draw(tostring(self.player.mon.level), 120, 64) + else + Font.draw(tostring(self.player.mon.level), 112, 64) + end end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, @@ -7080,11 +7177,9 @@ function BattleState:drawTextArea() pen = pen + Font.advanceOf(line[i]) end end - -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait - -- (_ContText) or a typed-out page (PromptText) holds the box; both write - -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) + -- home/text.asm:440 if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then - drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) + drawGlyph(0xEE, 18 * 8, 16 * 8) end elseif self.phase == "menu" and self.demo then -- engine/battle/core.asm:2038-2049 @@ -7175,7 +7270,7 @@ function BattleState:drawTextArea() -- the same call convention as the pre-battle screens (SummaryMenu, -- HallOfFame) that genuinely need the explicit data. Font.draw(def.type and TypeChart.displayName(def.type, self.data) or "", 16, 80) - local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = Pokemon.maxPP(def.pp, sel.ppUps) Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) end end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 128d3a4a..ff4f7db0 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -275,6 +275,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once -- the move has an added effect (#354) local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT" + -- engine/battle/core.asm:3577 + if moveInst and user.rageMove == moveInst then added = false end -- PlayApplyingAttackAnimation runs on both arms of the wOptions check -- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384) local hitFx = { sfx = hitSfx, @@ -301,6 +303,20 @@ function EffectRegistry.runDamaging(battle, ctx, record) hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer } table.insert(battle.queue, battle.nextInsert, hitRow) end + if h == 1 and record and record.explode then + local at + for i, item in ipairs(battle.queue) do + if item == hitRow then at = i break end + end + if at then + -- engine/battle/core.asm:6602 + table.insert(battle.queue, at + 1, { anim = "MEGA_PUNCH", + attackerIsPlayer = user.isPlayer, hit = { sfx = hitSfx, animType = 5 } }) + if (battle.nextInsert or 0) >= at then + battle.nextInsert = (battle.nextInsert or 0) + 1 + end + end + end local hadSub = target.substituteHP ~= nil local dealt = battle:applyDamage(target, dmg) totalDealt = totalDealt + dealt diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index efbd52b7..3542e39d 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -53,10 +53,8 @@ local STAT_LABEL = { -- --------------------------------------------------------------------- local function changeStage(battle, who, stat, delta, fromEnemy) + -- engine/battle/core.asm:5285 if fromEnemy and (who.substituteHP or who.mist) then - if who.mist then - return { Strings("%s is\nprotected by MIST!", displayName(who)), failed = true } - end return butItFailed(battle) end local cur = who.stages[stat] or 0 @@ -262,15 +260,17 @@ MoveEffects.primary = { mon.sleepTurns = 2 if not Status.toxicSurvivesCure(battle) then user.toxicCounter = nil end -- engine/battle/move_effects/heal.asm:35 - if hadStatus then - return { romText(battle.data, "_FellAsleepBecameHealthyText", - "%s\nfell asleep and\nbecame healthy!", displayName(user)) } - end - return { romText(battle.data, "_StartedSleepingEffect", "%s\nstarted sleeping!", displayName(user)) } + local lead = hadStatus + and romText(battle.data, "_FellAsleepBecameHealthyText", + "%s\nfell asleep and\nbecame healthy!", displayName(user)) + or romText(battle.data, "_StartedSleepingEffect", "%s\nstarted sleeping!", displayName(user)) + return { lead, romText(battle.data, "_RegainedHealthText", "%s\nregained health!", displayName(user)), + restLead = true, hpBarFirst = true } end if healFails(battle, mon) then return butItFailed(battle) end mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2)) - return { romText(battle.data, "_RegainedHealthText", "%s\nregained health!", displayName(user)) } + return { romText(battle.data, "_RegainedHealthText", "%s\nregained health!", displayName(user)), + hpBarFirst = true } end, LIGHT_SCREEN_EFFECT = function(battle, user) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index bafd6de2..a67a319b 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -251,7 +251,7 @@ local function drawMoveDetails(battle, move) if not move then return end local def = battle.data.moves[move.id] if not def then return end - local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def.pp, move.ppUps) love.graphics.setColor(0, 0, 0, 1) Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112) -- battle.data is game.data by reference (BattleState:startBattle sets it diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index dbc3a1e1..3f11c335 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -450,7 +450,9 @@ local function locationName(game, mapId, def) local entry = type(locations) == "table" and locations[mapId] if type(entry) == "table" then local name = entry.name or entry.label - if type(name) == "string" and name ~= "" then return name end + if type(name) == "string" and name ~= "" then + return (name:gsub("", "POKéMON")) + end end local def = game and game.data and game.data.maps and game.data.maps[mapId] if def and type(def.label) == "string" and def.label ~= "" then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2e38cc63..26215b34 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2886,6 +2886,17 @@ function SaveData.load(version) return data, recovered end +-- engine/menus/save.asm:622 +function SaveData.previousFileDiffers(save, injectedFs) + if type(save) ~= "table" then return false end + local fs = persistFs(injectedFs) + local disk = readTable(fs, (saveNames(save.version, injectedFs))) + local p = type(disk) == "table" and disk.player + if type(p) ~= "table" or type(p.name) ~= "string" or p.name == "" then return false end + if p.id == nil then return false end + return p.id ~= (save.player and save.player.id) +end + -- ------- validation and quarantine local function known(tbl, id) @@ -3000,7 +3011,7 @@ local function scrubKnownMon(mon, data) local base = ppInt(mdef and mdef.pp, 0) local ppUps = math.min(3, ppInt(slot.ppUps, 0)) if slot.ppUps ~= nil then slot.ppUps = ppUps end - slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5)) + slot.pp = ppInt(slot.pp, base + ppUps * math.min(7, math.floor(base / 5))) end end end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index df4dc16d..17e127c9 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -617,27 +617,32 @@ local function remainingFrames(src) end -- home/delay.asm:15 WaitForSoundToFinish polls CHAN5/CHAN6/CHAN8 +local WAITED_CHANNELS = { 5, 6, 8 } + function Sound.moveSfxBusy() pruneMoveSfx() - for _ in pairs(moveSfxChannels) do return true end + for _, ch in ipairs(WAITED_CHANNELS) do + if moveSfxChannels[ch] then return true end + end return false end function Sound.moveSfxWaitFrames() pruneMoveSfx() local worst = 0 - for _, cur in pairs(moveSfxChannels) do - local n = remainingFrames(cur.src) + for _, ch in ipairs(WAITED_CHANNELS) do + local cur = moveSfxChannels[ch] + local n = cur and remainingFrames(cur.src) if n then local okp, pitch = pcall(cur.src.getPitch, cur.src) if okp and type(pitch) == "number" and pitch > 0 then n = math.ceil(n / pitch * rate) end n = n + 2 - else + elseif cur then n = Sound.waitFrames(cur.src, 0) end - if n > worst then worst = n end + if n and n > worst then worst = n end end return worst end diff --git a/src/core/Timing.lua b/src/core/Timing.lua index 9dbd6dbe..e855c175 100644 --- a/src/core/Timing.lua +++ b/src/core/Timing.lua @@ -31,8 +31,8 @@ Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3 Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK Timing.WARP_FADE_IN = 0 --- home/overworld.asm:351-352 -- after a battle, before EnterMap -Timing.POST_BATTLE_RETURN = 10 +-- home/overworld.asm:351 +Timing.POST_BATTLE_RETURN = 20 -- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly / -- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite @@ -114,13 +114,8 @@ Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6 Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9 Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3 --- Type 4 -- the player's damaging move with no added effect, and so the --- single most common animation in the game -- is AnimationBlinkMon --- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5 --- + show + DelayFrames 5. The asm's own comment calls it "a second or --- two"; the port ran it in 20 frames, three times too fast, which is a --- large part of why trading blows felt hurried. -Timing.BLINK_MON = 60 +-- engine/battle/animations.asm:1360 +Timing.BLINK_MON = 78 -- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT -- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`. diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 9bd942eb..30caa9d0 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -9,7 +9,8 @@ local GameVersion = require("src.core.GameVersion") local CacheContract = {} -- engine/battle/animations.asm:2418, engine/battle/core.asm:2051 -CacheContract.FORMAT = "rom-cache-v13-gen1:" +-- engine/movie/title.asm:97 +CacheContract.FORMAT = "rom-cache-v14-gen1:" CacheContract.VERSION_FORMAT = { -- v11: Gen 2 maps carry their object list's ROM address, which a .sav -- export re-anchoring a save onto another map writes back into @@ -23,7 +24,8 @@ CacheContract.VERSION_FORMAT = { -- engine/overworld/map_sprites.asm:181, engine/battle/animations.asm:2600 -- data/pikachu/pikachu_pic_animation.asm:340, engine/movie/title.asm:76 -- engine/pikachu/pikachu_pic_animation.asm:412, engine/battle/core.asm:2135-2138 - yellow = "rom-cache-v12-yellow5:", + -- engine/battle/read_trainer_party.asm:87 + yellow = "rom-cache-v12-yellow6:", -- v8: M4A tracks retain reachable patterns and explicit entry offsets. firered = "rom-cache-v25-firered:", leafgreen = "rom-cache-v10-leafgreen:", @@ -50,6 +52,8 @@ CacheContract.REQUIRED_FILES = { -- engine/items/town_map.asm:296, 150 "assets/generated/townmap/nest.png", "assets/generated/townmap/up_arrow.png", + -- engine/pokemon/status_screen.asm:236 + "assets/generated/fonts/bold_p.png", } CacheContract.VERSION_REQUIRED_FILES = { diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 37f82511..2e108874 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -473,6 +473,19 @@ function RomExtractor:extractFont() self:save(ed, "fonts/ed.png") data.imageEd = "assets/generated/fonts/ed.png" end + -- engine/pokemon/status_screen.asm:236 + local pSymbol = self:symbol("PTile") + local pRaw = self.rom:bytes(pSymbol.bank, pSymbol.address, 8) + local boldP = ImageWriter.blank(8, 8, 0, 0, 0, 0) + for y = 0, 7 do + local row = pRaw[y + 1] + for x = 0, 7 do + if bit.band(row, 2 ^ (7 - x)) ~= 0 then + boldP:setPixel(x, y, 0, 0, 0, 1) + end + end + end + self:save(boldP, "fonts/bold_p.png") self:write("font", data) self:tick("Fonts", 2, 2) return data @@ -1387,6 +1400,32 @@ function RomExtractor:extractTrainers() } self:tick("Trainers", index, #order) end + -- pokeyellow engine/battle/read_trainer_party.asm:87 + if self.symbols.SpecialTrainerMoves then + local special = self:symbol("SpecialTrainerMoves") + local bank, at = special.bank, special.address + while true do + local class = self.rom:byte(bank, at) + if class == 0xFF then break end + local partyNo = self.rom:byte(bank, at + 1) + at = at + 2 + local rows = {} + while true do + local mon = self.rom:byte(bank, at) + at = at + 1 + if mon == 0 then break end + rows[#rows + 1] = { mon = mon, slot = self.rom:byte(bank, at), + move = self:move(self.rom:byte(bank, at + 1)) } + at = at + 2 + end + local trainer = order[class] and out["OPP_" .. order[class]] + if trainer then + trainer.specialMoves = trainer.specialMoves or {} + trainer.specialMoves[#trainer.specialMoves + 1] = + { party = partyNo, moves = rows } + end + end + end -- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic -- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has -- the symbol, so Red and Blue skip this (#439). @@ -1530,6 +1569,11 @@ function RomExtractor:extractText() texts[label] = self.rom:readString(s.bank, s.address, self.manifest.charmap) end end + -- pokered engine/items/item_effects.asm:828 + if self.symbols["ItemUseMedicine.emptyPartyText"] then + texts["ItemUseMedicine.emptyPartyText"] = self:decodeTextCommands( + self:symbol("ItemUseMedicine.emptyPartyText"), {}) + end local trainerHeaders = {} for mapLabel, headers in pairs(metadata.trainerHeaders) do local converted = {} @@ -1926,7 +1970,7 @@ function RomExtractor:extractField() self:raw1bpp("Version_GFX", 80, 8, "title/red_version.png"); tick() self:raw2bpp("PlayerCharacterTitleGraphics", 40, 56, - "title/player.png", { matte = true }); tick() + "title/player.png", { transparent = true }); tick() self:raw2bpp("NintendoCopyrightLogoGraphics", 152, 8, "title/copyright.png"); tick() self:raw2bpp("GameFreakLogoGraphics", 72, 8, diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index a3cc89c9..f98acd03 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -110,6 +110,20 @@ function ItemEffects.isBattleMedicine(id) or id == "REVIVE" or id == "MAX_REVIVE" end +-- engine/items/item_effects.asm:803 +function ItemEffects.notTimeInBattle(id, itemDef) + return (VITAMINS[id] or STONES[id] or id == "PP_UP" + or id == "RARE_CANDY" or id == "COIN_CASE" + or REPELS[id] or (itemDef and itemDef.machine)) and true or false +end + +-- engine/items/item_effects.asm:805-808 +function ItemEffects.usesMedicineMenu(id) + return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION" + or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE" + or id == "RARE_CANDY" or VITAMINS[id] or false +end + -- Does this item need a party-member target? -- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection function ItemEffects.needsTarget(id, itemDef, data) @@ -238,10 +252,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / -- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle -- (jp nz, ItemUseNotTime) - if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" - or itemId == "RARE_CANDY" or itemId == "COIN_CASE" - or REPELS[itemId] - or (itemDef and itemDef.machine)) then + if battle and ItemEffects.notTimeInBattle(itemId, itemDef) then return "failed", { notTime(data, save) } end @@ -349,6 +360,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end return "consumed", { used }, { useJingle = true, + -- engine/items/item_effects.asm:1657 + battleAnim = "XSTATITEM_ANIM", afterMessages = { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }, } @@ -384,8 +397,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" local function restore(mv) local mdef = data.moves[mv.id] - local maxPP = mdef and (mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)) - if maxPP and mv.pp < maxPP then + local maxPP = mdef and require("src.pokemon.Pokemon").maxPP(mdef.pp, mv.ppUps) + -- engine/items/item_effects.asm:2078 + if maxPP and (mv.pp < maxPP or (full and (mv.ppUps or 0) > 0)) then mv.pp = full and maxPP or math.min(maxPP, mv.pp + 10) return true end @@ -578,8 +592,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local mdef = mv and data.moves[mv.id] if mdef and (mv.ppUps or 0) < 3 then mv.ppUps = (mv.ppUps or 0) + 1 - -- each PP UP adds maxPP/5 uses on top of the base maximum - mv.pp = mv.pp + math.floor(mdef.pp / 5) + mv.pp = mv.pp + require("src.pokemon.Pokemon").ppUpBonus(mdef.pp) return "consumed", { romText(data, "_PPIncreasedText", "%s's PP\nincreased!", mdef.name) } end @@ -674,6 +687,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end return "itemfinder" end + if itemId == "OAKS_PARCEL" then + -- engine/items/item_effects.asm:1918-1919 + return "failed", { data.text._ItemUseNotYoursToUseText } + end if itemId == "COIN_CASE" then return "failed", { romText(data, "_CoinCaseNumCoinsText", "Coin count:\n%d", save.coins or 0) } diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 8e95d58c..1787e4f2 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -21,6 +21,7 @@ local LinkItems = require("src.link.LinkItems") local Logger = require("src.core.Logger") local Party = require("src.pokemon.Party") local Protocol = require("src.link.Protocol") +local RomText = require("src.core.RomText") local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") local Strings = require("src.core.Strings") @@ -659,7 +660,8 @@ function LinkBattle.new(game, net, opts) end self.openItems = function(s) - s:say(Strings("Items can't be\nused in a link\nbattle!")) + -- engine/battle/core.asm:2178 + s:say(RomText(game.data, "_ItemsCantBeUsedHereText", "Items can't be\nused here.")) s.phase = "messages" s.afterQueue = "menu" end @@ -710,8 +712,9 @@ function LinkBattle.new(game, net, opts) return end end - s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name, - theirName)) + -- engine/battle/core.asm:1157 + s:sayNext(RomText(game.data, "_LinkBattleLostText", "%s lost to\n%s!", + game.save.player.name, theirName)) s.result = "lose" s.afterQueue = "finish" end @@ -723,8 +726,9 @@ function LinkBattle.new(game, net, opts) return end end - s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName, - game.save.player.name)) + -- engine/battle/core.asm:934 + s:sayNext(RomText(game.data, "_TrainerDefeatedText", "%s defeated\n%s!", + game.save.player.name, theirName)) s.result = "win" s.afterQueue = "finish" end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index db20f2f0..f52a9352 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -509,7 +509,6 @@ function LinkState:updateTrade(input) -- would lose it and a reset would clone the sent mon (#222). Guarded so -- headless LinkBattle-style fake games with no writeSave are unaffected. if self.game.writeSave then self.game:writeSave() end - local name = received.nickname or self.game.data.pokemon[received.species].name local game = self.game -- engine/link/cable_club.asm:870 #758 local again = self.verdict == "full" and self.net ~= nil @@ -530,27 +529,22 @@ function LinkState:updateTrade(input) playerOtId = sent.otId or game.save.player.id, enemyOtId = received.otId, onDone = function() - game.stack:push(TextBox.new(game, - Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name), - function() - local nextRound = function() + -- engine/link/cable_club.asm:850-866 + local function completed() + game.stack:push(TextBox.new(game, Strings("Trade completed!"), + function() if again then self:beginRound() end - end - if evoTo then - -- via="TRADE": a trade evolution cannot be B-cancelled - -- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213). - -- Re-save once the evolution movie finishes so the evolved - -- species (not the pre-evo landed by t:apply) is what persists, - -- keeping disk in step with the autosave above (#222). - require("src.pokemon.Evolution").evolve(game, received, evoTo, - function() - if game.writeSave then game:writeSave() end - nextRound() - end, "TRADE") - else - nextRound() - end - end)) + end, { auto = { delay = 50 } })) + end + if evoTo then + require("src.pokemon.Evolution").evolve(game, received, evoTo, + function() + if game.writeSave then game:writeSave() end + completed() + end, "TRADE") + else + completed() + end end, }) return @@ -607,13 +601,19 @@ function LinkState:updateTrade(input) self.index = self.index + 1 end elseif t.stage == "picking" and input:wasPressed("right") then - if t.theirParty and #t.theirParty > 0 then + -- engine/link/cable_club.asm:428-447 + if self.side == "mine" and t.theirParty and #t.theirParty > 0 then self.side = "theirs" - self.theirIndex = math.min(self.theirIndex, #t.theirParty) + self.theirIndex = math.min(self.index, #t.theirParty) end elseif t.stage == "picking" and input:wasPressed("left") then - self.side = "mine" - elseif self.confirmed == nil and input:wasPressed("b") then + -- engine/link/cable_club.asm:372-388 + if self.side == "theirs" then + self.side = "mine" + self.index = math.max(1, math.min(self.theirIndex, #self.game.save.party)) + end + elseif t.stage == "confirming" and self.confirmed == nil + and input:wasPressed("b") then -- once confirm=true has been sent to the peer, backing out here -- would desync the two sides (the peer may already be committing -- the trade) -- B is dead after that, matching the A branch's own diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 0fb16fea..f32e7f78 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -1216,7 +1216,9 @@ function TradeSession:apply(game) -- a mod validates its own extra namespace here, before the mon is filed Runtime.emit("pokemon.received", { mon = received, from = "link", peerName = self.peerName }) - self.party[self.myPick] = received + -- engine/link/cable_club.asm:800 + table.remove(self.party, self.myPick) + self.party[#self.party + 1] = received -- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the -- companion away is the biggest happiness hit and zeroes the mood if game and sent then diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 0e9499cd..66c6627a 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -5,26 +5,22 @@ local Stats = require("src.pokemon.Stats") local Pokemon = {} --- Starting moves: level-1 moves plus learnset entries at or below the level, --- keeping the most recent four (engine/pokemon/learn_move.asm behavior). +-- engine/pokemon/evos_moves.asm:383 function Pokemon.movesAtLevel(speciesDef, level) local moves = {} - local function add(id) - for _, existing in ipairs(moves) do - if existing == id then return end - end - table.insert(moves, id) - end for _, m in ipairs(speciesDef.level1Moves) do - add(m) + if #moves < 4 then table.insert(moves, m) end end for _, entry in ipairs(speciesDef.learnset) do - if entry.level <= level then - add(entry.move) + if entry.level > level then break end + local known = false + for _, existing in ipairs(moves) do + if existing == entry.move then known = true break end + end + if not known then + if #moves >= 4 then table.remove(moves, 1) end + table.insert(moves, entry.move) end - end - while #moves > 4 do - table.remove(moves, 1) end return moves end @@ -84,9 +80,17 @@ function Pokemon.new(data, species, level, rng) } end +-- engine/items/item_effects.asm:2418 +function Pokemon.ppUpBonus(basePP) + return math.min(7, math.floor((basePP or 0) / 5)) +end + +function Pokemon.maxPP(basePP, ppUps) + return (basePP or 0) + (ppUps or 0) * Pokemon.ppUpBonus(basePP) +end + -- Pokémon Center / blackout heal (engine/events/heal_party.asm -- HealParty): full HP, status cleared, and every move's PP restored to --- its base plus the PP-Up bonus (RestoreBonusPP adds maxPP/5 per PP UP). function Pokemon.heal(mon) mon.hp = mon.stats.hp mon.status = nil @@ -95,7 +99,7 @@ function Pokemon.heal(mon) for _, mv in ipairs(mon.moves) do local mdef = moves[mv.id] if mdef then - mv.pp = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5) + mv.pp = Pokemon.maxPP(mdef.pp, mv.ppUps) end end end diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index aa214344..92519880 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -23,32 +23,10 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0, local FLASH_HOLD = 2 -- frames per palette step local FLASH_CYCLES = 3 --- The screen stays black after the wipe lands. Shrink and Split ask for it --- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`: --- battle_transitions.asm:390-392 and 422-424); the other six get the same gap --- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/ --- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables --- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185), --- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and --- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between --- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port --- has no load to hide behind, so the hold has to be explicit (#315). This is --- a frame budget for that work, not a number pokered states. --- --- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the --- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4, --- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1, --- Delay3 after EnableLCD 3 -- and then noted that the two sprite --- decompressors (UncompressSpriteFromDE for the 7x7 front pic, --- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta --- decoders whose cost cannot be cycle-counted from the asm at all. So the --- derivation bottoms out around 25-30 with an unbounded remainder, and 60 --- was set by ear against the real ROM ("a solid second") and confirmed as --- ~95% right rather than frame-matched. The credible range is 30-60; do NOT --- "correct" this down toward the floor on the strength of the derivation, --- because the omitted decompressors are exactly the unbounded part. It --- wants a frame-by-frame capture against hardware to pin exactly. -local BLACK_HOLD = 60 +-- engine/battle/core.asm:6151 +local BLACK_HOLD = 70 +-- engine/battle/battle_transitions.asm:391 +local BLACK_HOLD_DELAYED = 91 local TILE = 8 local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles @@ -242,9 +220,9 @@ BattleTransition.STYLES = { circle = { kind = "wipe", frames = 60, flash = true }, spiralout = { kind = "wipe", frames = 120 }, hstripes = { kind = "wipe", frames = 60 }, - shrink = { kind = "wipe", frames = 54 }, + shrink = { kind = "wipe", frames = 54, hold = BLACK_HOLD_DELAYED }, vstripes = { kind = "wipe", frames = 54 }, - split = { kind = "wipe", frames = 54 }, + split = { kind = "wipe", frames = 54, hold = BLACK_HOLD_DELAYED }, } -- the eight wipes plus Transition's two warp fades: one registrant owns @@ -323,6 +301,7 @@ function BattleTransition.new(game, onDone, opts) -- only the circle wipes flash first (battle_transitions.asm:585,628) self.phase = def.flash and "flash" or "wipe" self.wipeLen = def.frames + self.hold = def.hold or BLACK_HOLD return self end @@ -334,7 +313,7 @@ function BattleTransition:update(dt) self.t = 0 end else - if self.t >= self.wipeLen + BLACK_HOLD then + if self.t >= self.wipeLen + self.hold then self.game.stack:pop() if self.onDone then self.onDone() end end diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 7d016318..98b4775d 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -47,6 +47,9 @@ local STATUS_PAGES = { image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 }, { id = "battle_hud_2", image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 }, + -- engine/pokemon/status_screen.asm:98-101 + { id = "bold_p", + image = "assets/generated/fonts/bold_p.png", base = 0x72, count = 1 }, } -- engine/menus/naming_screen.asm:93 @@ -120,6 +123,9 @@ function HudTiles.namingTile(code, x, y, tint) put(namingTiles[code], x, y, tint) end +-- home/load_font.asm:33 +HudTiles.battleExtraTile = HudTiles.namingTile + -- lazy: the next tile() rebuilds every page from the search path function HudTiles.invalidate() tiles = nil diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 087b763a..9406d2bd 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -514,6 +514,13 @@ function PaletteFX.usesSpriteObp(mode) return mode == "ogred" and not GameVersion.isYellow() end +-- engine/battle/animations.asm:551 +function PaletteFX.runsNotSGB(mode) + mode = mode or PaletteFX.mode + return mode == "og" or mode == "og_inv" or mode == "classic" + or PaletteFX.usesSpriteObp(mode) +end + -- ------- post-zone sprite redraw (OG RED): OBP-baked draws recorded here and -- replayed by Renderer:endFrame on top of the zone pass (usesSpriteObp, #301) local spriteRedraws = {} diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 30019ee1..03363172 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -77,7 +77,6 @@ function TileRenderer.hasImage(path) return imageCache[path] ~= nil end -- ------------------------------------------------------------------ -- Tile animation (home/vcopy.asm): tilesets with TILEANIM_WATER[_FLOWER] --- rotate water tile $14 one pixel every 20 frames (4 steps right, 4 -- left) and cycle flower tile $03 through 3 frames. -- Those two cycles are the *defaults* a vanilla tileset record derives -- from its `animation` string; a tileset that carries `animatedTiles` @@ -311,13 +310,17 @@ end function TileRenderer.defaultAnimatedTiles(tileset) local out = {} local anim = tileset.animation - if anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" then + local flower = anim == "TILEANIM_WATER_FLOWER" + -- home/vcopy.asm:387-393 + local period = flower and ANIM_PERIOD + 1 or ANIM_PERIOD + if anim == "TILEANIM_WATER" or flower then out[#out + 1] = { tile = WATER_TILE, kind = "hshift", - period = ANIM_PERIOD, offsets = WATER_OFFSETS } + period = period, phase = flower and 1 or 0, + offsets = WATER_OFFSETS } end - if anim == "TILEANIM_WATER_FLOWER" then + if flower then out[#out + 1] = { tile = FLOWER_TILE, kind = "frames", - period = ANIM_PERIOD, images = FLOWER_IMAGES, + period = period, images = FLOWER_IMAGES, sequence = FLOWER_FRAMES } end local spinners = TileRenderer.SPINNER_ARROW_TILES[tileset.id] @@ -363,14 +366,14 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc) local sequence = {} for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end return { tiles = tiles, textures = textures, sequence = sequence, - period = period, spec = gbc and spec or nil } + period = period, phase = spec.phase, spec = gbc and spec or nil } elseif spec.kind == "frames" then local sequence = spec.sequence if not (spec.images and sequence and #sequence > 0) then return nil end local textures = getFrameImages(spec.images, colors, gbc and gbc.key) if not textures then return nil end return { tiles = tiles, textures = textures, sequence = sequence, - period = period, spec = gbc and spec or nil } + period = period, phase = spec.phase, spec = gbc and spec or nil } elseif spec.kind == "toggle" then if gbc then return nil end local image = getToggleImage(spec, tilesetImagePath, perRow) @@ -736,7 +739,11 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh) -- overworld frame, so allocating a fresh Quad here churned the GC local q = self.borderQuad if self.borderFillMode == "water" and self.borderWaterTextures then - local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1 + local flower = self.map and self.map.tileset + and self.map.tileset.animation == "TILEANIM_WATER_FLOWER" + local period = flower and ANIM_PERIOD + 1 or ANIM_PERIOD + local step = math.floor((animFrame + (flower and 1 or 0)) / period) + % #WATER_OFFSETS + 1 local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1] if not tex then return end if q then @@ -982,7 +989,8 @@ function TileRenderer:drawAnimated(camX, camY) -- already-static window tile shows through unchanged if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end else - local step = math.floor(animFrame / anim.period) % #anim.sequence + 1 + local step = math.floor((animFrame + (anim.phase or 0)) / anim.period) + % #anim.sequence + 1 batch:setTexture(anim.textures[anim.sequence[step]]) love.graphics.draw(batch, x, y) end @@ -1001,7 +1009,8 @@ function TileRenderer:drawTile(tile, x, y) if q then love.graphics.draw(anim.textures[1], q, x, y) end end elseif anim.sequence and anim.textures then - local step = math.floor(animFrame / anim.period) % #anim.sequence + 1 + local step = math.floor((animFrame + (anim.phase or 0)) / anim.period) + % #anim.sequence + 1 local tex = anim.textures[anim.sequence[step]] if tex then love.graphics.draw(tex, x, y) end end diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 421059c3..95da56b3 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -98,6 +98,7 @@ function Transition.new(game, onMidpoint, onDone, warp, opts) self.color = opts.color or { 0, 0, 0 } local style = styleOf(game, "warp_fade") self.frames = opts.frames or style.frames or FRAMES + self.hold = opts.hold if opts.framesIn ~= nil then self.framesIn = opts.framesIn elseif warp then @@ -119,7 +120,8 @@ end function Transition:update(dt) self.t = self.t + 1 - local len = (self.phase == "out") and self.frames or self.framesIn + local len = (self.phase == "out") and self.frames + (self.hold or 0) + or self.framesIn if self.t >= len then self.t = 0 if self.phase == "out" then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3027ccc4..1a12f7d1 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -842,6 +842,10 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, gotText) -- engine/events/give_pokemon.asm:46 if gotText and ctx.runner then Commands.text_sound(ctx, "Get_Item1") + -- engine/events/give_pokemon.asm:71-74 + if not gift.nickname and not skipNickname then + ctx.textOpts.auto.wait = false + end Commands.show_text(ctx, "_GotMonText", { RAM = species }) end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks @@ -1175,6 +1179,16 @@ function Commands.trade(ctx, tradeIndex, doneFlag) onDone = function() runner:resume() end, }) runner:yield() + -- pokeyellow engine/events/in_game_trades.asm:236 + if require("src.core.GameVersion").isYellow() then + local Evolution = require("src.pokemon.Evolution") + local evolved = Evolution.pendingFor(ctx.game, newMon, { kind = "trade" }) + if evolved then + Evolution.evolve(ctx.game, newMon, evolved, + function() runner:resume() end, "TRADE") + runner:yield() + end + end -- TradedForText carries sound_get_key_item after the text, so the jingle -- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the -- dialogset's thanks diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index cc533c13..dfdea110 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -2,6 +2,7 @@ -- opts.battle = BattleState when opened mid-battle (balls throwable, -- using an item consumes the turn). +local GameVersion = require("src.core.GameVersion") local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") local Runtime = require("src.mods.Runtime") @@ -51,8 +52,13 @@ local function consume(game, id, list) end end -local function save_name(game) - return game.save.player.name +-- engine/items/item_effects.asm:2359-2369 +local function bicycleText(game, on) + local t = game.data.text + game.stringBuffer = game.data.items.BICYCLE.name + local first = on and t._GotOnBicycleText1 or t._GotOffBicycleText1 + local second = on and t._GotOnBicycleText2 or t._GotOffBicycleText2 + return first:gsub("{DONE}$", "") .. "\n" .. second end local function showMessages(game, msgs, onDone, opts) @@ -205,12 +211,12 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) closeBag() game.save.onBike = false Music.playMap(game.data, ow and ow.map.id, false) - showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) }) + showMessages(game, { bicycleText(game, false) }) elseif bikeAllowed() then closeBag() game.save.onBike = true Music.playMap(game.data, ow.map.id, true) - showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) }) + showMessages(game, { bicycleText(game, true) }) else -- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and -- .useItem_closeMenu loops back (item_effects.asm:2305-2319) @@ -297,12 +303,12 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) if result == "itemfinder" then local ow = game.overworld local t = game.data.text + -- engine/items/item_effects.asm:1925 + closeBag() if ow and ow:hasHiddenItemLeft() then - showMessages(game, { t._ItemfinderFoundItemText - or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") }) + showMessages(game, { t._ItemfinderFoundItemText }) else - showMessages(game, { t._ItemfinderFoundNothingText - or Strings("Nope! ITEMFINDER\nisn't responding.") }) + showMessages(game, { t._ItemfinderFoundNothingText }) end return end @@ -487,7 +493,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end) return end - if battle then + if battle and extra and extra.battleAnim then + closePicker() + list:close() + showMessages(game, payload, function() + spent(extra.afterMessages or {}, { anim = extra.battleAnim }) + end, TextBox.soundOpts(game, "Heal_Ailment")) + elseif battle then closePicker() list:close() showUseMessages(game, payload, function() spent({}) end, extra) @@ -580,7 +592,9 @@ end local function useItem(game, battle, id, list) local def = game.data.items[id] -- ItemUseTMHM checks wIsInBattle before BootedUpTMText - if battle and def and def.machine then + -- engine/items/item_effects.asm:803 + if battle and def and (def.machine + or (not def.effect and ItemEffects.notTimeInBattle(id, def))) then local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle) showMessages(game, payload) return @@ -603,6 +617,13 @@ local function useItem(game, battle, id, list) end }) return end + -- engine/items/item_effects.asm:823-828 + if #game.save.party == 0 and ItemEffects.usesMedicineMenu(id) then + local t = game.data.text + showMessages(game, { GameVersion.isYellow() and t._DontHavePokemonText + or t["ItemUseMedicine.emptyPartyText"] }) + return + end pickTargetAndUse(game, battle, id, list) else useOn(game, battle, id, nil, list) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index c0e6ac43..76dcb8ac 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -36,7 +36,7 @@ local function monRow(game, mon, value) local def = game.data.pokemon[mon.species] return { label = mon.nickname or def.name, - sub = Strings(":L%d", mon.level), + level = mon.level, value = value, } end @@ -54,8 +54,9 @@ end -- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen -- action + STATS + CANCEL. STATS shows the status screen and returns --- here; CANCEL/B goes back to the list. -local function monSubmenu(game, action, mon, onAction) +local function monSubmenu(game, list, action, mon, onAction) + -- engine/pokemon/bills_pc.asm:225-226 + local function back() list:close() end game.stack:push(Menu.new(game, { { label = action, onSelect = onAction }, { @@ -65,15 +66,12 @@ local function monSubmenu(game, action, mon, onAction) require("src.ui.Screens").push(game, "SummaryMenu", mon) end, }, - { label = Strings("CANCEL") }, - }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) + { label = Strings("CANCEL"), onSelect = back }, + }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true, onCancel = back })) end --- After a successful transfer: close the mon list (BoxMenu stays beneath --- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu). local function afterTransfer(game, list, text) - list:close() - game.stack:push(TextBox.new(game, text)) + game.stack:push(TextBox.new(game, text, function() list:close() end)) end local function withdraw(game) @@ -102,7 +100,7 @@ local function withdraw(game) list.hollowIndex = list.index -- home/list_menu.asm:91 local mon = box[item.value] if not mon then return end - monSubmenu(game, Strings("WITHDRAW"), mon, function() + monSubmenu(game, list, Strings("WITHDRAW"), mon, function() if #game.save.party >= Party.MAX then list.footer = Strings("The party is full!") return @@ -160,7 +158,7 @@ local function deposit(game) or Strings("There isn't any\nresponse..."))) return end - monSubmenu(game, Strings("DEPOSIT"), mon, function() + monSubmenu(game, list, Strings("DEPOSIT"), mon, function() if #game.save.party <= 1 then list.footer = Strings("You need at least\none POKéMON!") return @@ -218,18 +216,26 @@ local function release(game) :gsub("{RAM:wNameBuffer}", name)))) return end - game.stack:push(TextBox.new(game, - (t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name)) - :gsub("{RAM:wStringBuffer}", name), nil, { - defaultNo = true, noSound = true, + local question = (t._OnceReleasedText + or Strings("Once released,\n%s is\ngone forever. OK?", name)) + :gsub("{RAM:wStringBuffer}", name) + game.stack:push(TextBox.new(game, question, nil, { + noSound = true, choice = function(yes) - if not yes then return end + if not yes then + -- engine/pokemon/bills_pc.asm:307-309 + list.footer, list.listPrompt = question, question + return + end table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) + -- engine/pokemon/bills_pc.asm:318 game.stack:push(TextBox.new(game, ((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name)) - :gsub("{RAM:wStringBuffer}", name)))) - list:removeCurrent() + :gsub("{RAM:wStringBuffer}", name)), function() + list:removeCurrent() + list:close() + end)) end, })) end, @@ -302,10 +308,12 @@ local function changeBox(game) end -- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay -local function drawChrome(game) - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("What?"), 8, 112) +local function drawChrome(game, what) + if what then + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("What?"), 8, 112) + end Font.drawBox(9, 14, 11, 4) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("BOX No."), 80, 128) @@ -392,9 +400,12 @@ function BoxMenu.new(game) return baseUpdate(self, dt) end local baseDraw = menu.draw + local yellow = require("src.core.GameVersion").isYellow() function menu:draw() + -- pokeyellow engine/pokemon/bills_pc.asm:119-124 + if yellow then Font.drawBox(0, 12, 20, 6) end baseDraw(self) - drawChrome(game) + drawChrome(game, not yellow) end menu.isMenu = true return menu diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 3f14b5bf..c964cdae 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -50,8 +50,11 @@ function ChoiceBox:update(dt) end return end - if input:wasPressed("up") or input:wasPressed("down") then - self.index = self.index == 1 and 2 or 1 + -- home/window.asm:56-83 + if input:wasPressed("up") then + self.index = 1 + elseif input:wasPressed("down") then + self.index = 2 elseif input:wasPressed("a") then -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike if not self.noSound then diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index acae56d2..52642340 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -48,6 +48,11 @@ local function descPages(game, def, forceOwned) local owned = ownedFor(game, def, forceOwned) local text = owned and e.text and game.data.text[e.text] or nil if not text then return nil end + local dexEnd = true + -- data/pokemon/dex_text.asm:1088 + if text:sub(-6) == "{DONE}" then + text, dexEnd = text:sub(1, -7), false + end local pages = {} for chunk in (text .. "\f"):gmatch("(.-)\f") do local lines = {} @@ -58,8 +63,10 @@ local function descPages(game, def, forceOwned) if #lines > 0 then pages[#pages + 1] = lines end end if #pages == 0 then return nil end - local last = pages[#pages] - last[#last] = last[#last] .. "." + if dexEnd then + local last = pages[#pages] + last[#last] = last[#last] .. "." + end return pages end @@ -226,7 +233,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta if sprite then -- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped) local w, h = sprite:getDimensions() - local x = 8 + math.floor((8 - w / 8) / 2) * 8 + local x = 8 + math.max(0, 56 - math.floor((8 - w / 8) / 2) * 8 - w) local y = 8 + (7 - h / 8) * 8 love.graphics.draw(sprite, x + w, y, 0, -1, 1) -- the unshaded pass needs the pic bounds (#350) @@ -243,9 +250,16 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta -- same number width as the list (constants.dexDigits), so a dex past 999 -- prints the extra digit everywhere at once local digits = (game.data.constants or {}).dexDigits or 3 - -- engine/menus/pokedex.asm:478 - Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), - 16, 64) + -- engine/menus/pokedex.asm:478-485 + if Strings.lookup("No.") == "No." then + require("src.render.HudTiles").statusTile(0x74, 16, 64) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(0xF2, 24, 64) + Font.draw(("%0" .. digits .. "d"):format(def.dex or 0), 32, 64) + else + Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), + 16, 64) + end local owned = ownedFor(game, def, forceOwned) local metric = e.heightM ~= nil local numbers = owned and e.heightFt @@ -281,7 +295,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta else Font.draw(("%2d"):format(e.heightFt), 96, 48) Font.draw(("%02d"):format(e.heightIn or 0), 120, 48) - Font.draw(("%6.1f"):format((e.weight or 0) / 10), 88, 64) + -- engine/menus/pokedex.asm:547-561 + Font.draw((("%6.1f"):format((e.weight or 0) / 10):gsub("%.", "")), 88, 64) end end local pages = descPages(game, def, forceOwned) diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index 7d489e95..38b57d8f 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -343,16 +343,18 @@ end function HallOfFame:drawPic(img, trueColor) if not img then return end love.graphics.setColor(1, 1, 1, 1) - local x = self.scrollX or PIC_X - love.graphics.draw(img, x, PIC_Y) + local w, h = img:getDimensions() + -- home/pics.asm:68-76 + local x = (self.scrollX or PIC_X) + math.floor((8 - w / 8) / 2) * 8 + local y = PIC_Y + math.max(0, 56 - h) + love.graphics.draw(img, x, y) -- SET_PAL_POKEMON_WHOLE_SCREEN (HoFShowMonOrPlayer) colors the WHOLE -- screen in the mon's palette, so sgbPalettes above hands the blit a -- whole-canvas zone -- and a full-color pic has to sit that remap out. -- Report the rect the pic covers for the unshaded pass, the way -- SummaryMenu does for the status screen pic (#637; #430). if trueColor then - require("src.render.PaletteFX").markTrueColor(x, PIC_Y, - img:getDimensions()) + require("src.render.PaletteFX").markTrueColor(x, y, w, h) end end @@ -393,8 +395,9 @@ function HallOfFame:drawPlayerStats() Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), 5 * 8, 7 * 8) Font.draw(Strings("MONEY"), 1 * 8, 9 * 8) - -- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard - Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8) + -- engine/movie/hall_of_fame.asm:238-241 + local digits = tostring(math.max(0, math.floor(save.money or 0))) + Font.draw("¥" .. digits, (4 + 6 - #digits) * 8, 10 * 8) end function HallOfFame:draw() diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 85f62744..616da3ea 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -332,12 +332,20 @@ 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.sub then + if item.level then -- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461) + -- home/pokemon.asm:335-345 + local lx = ITEM_QTY_X + if item.level < 100 then + require("src.render.HudTiles").battleExtraTile(0x6E, lx, y + 8) + lx = lx + 8 + end + Font.draw(tostring(item.level), lx, y + 8) + elseif item.sub then Font.draw(item.sub, ITEM_QTY_X, y + 8) elseif item.price then - -- home/list_menu.asm:410-424 - Font.draw(item.price, ITEM_QTY_END - Font.width(item.price), y + 8) + -- home/list_menu.asm:421-424 + Font.draw(item.price, ITEM_QTY_END + 8 - Font.width(item.price), y + 8) elseif item.count then -- '×' at column 14, PrintNumber's two right-aligned digits after it -- (home/list_menu.asm:479-490) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 1a57980e..41c1c929 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -35,7 +35,12 @@ function Menu.new(game, items, opts) end self.rowStep = opts.rowStep or 2 -- home/window.asm:56-83 - self.noWrap = opts.noWrap or false + if opts.noWrap ~= nil then + self.noWrap = opts.noWrap + else + local GameVersion = require("src.core.GameVersion") + self.noWrap = GameVersion.generation(GameVersion.get()) == 1 + end -- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox) self.title = opts.title self.itemY = opts.itemY diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index c602ffdd..7da86d24 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -155,7 +155,8 @@ function MoveLearnMenu:finish(learned) "\f%s forgot\n%s!\fAnd...", name, self.forgot) .. "\f" .. romText(game.data, "_LearnedMove1Text", "%s learned\n%s!", name, mdef.name) - opts = { pauseSounds = { "Swap" }, auto = { sound = function() + -- engine/pokemon/learn_move.asm:189 + opts = { pauseSounds = { "Swap" }, waitButton = false, auto = { sound = function() return require("src.core.Sound").play(game.data, self.learnedSound) end, wait = true } } else diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index fb59ae0f..32309384 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -55,7 +55,7 @@ local GRID_UPPER = { { "J", "K", "L", "M", "N", "O", "P", "Q", "R" }, { "S", "T", "U", "V", "W", "X", "Y", "Z", " " }, { "×", "(", ")", ":", ";", "[", "]", "", "" }, - { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "-", "?", "!", "♂", "♀", "/", "", ",", "ED" }, { "lower case" }, } local GRID_LOWER = { @@ -63,7 +63,7 @@ local GRID_LOWER = { { "j", "k", "l", "m", "n", "o", "p", "q", "r" }, { "s", "t", "u", "v", "w", "x", "y", "z", " " }, { "×", "(", ")", ":", ";", "[", "]", "", "" }, - { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "-", "?", "!", "♂", "♀", "/", "", ",", "ED" }, { "UPPER CASE" }, } diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 75fc4c66..771a1891 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -798,7 +798,9 @@ function OakSpeech:draw() -- it like the sprite buffer does ((8 - w) >> 1) tiles across, -- bottom-aligned local w, h = self.pic:getDimensions() - local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0) + local pad = math.floor((8 - w / 8) / 2) * 8 + if self.picFlip then pad = math.max(0, 56 - pad - w) end + local x = 48 + pad + (self.picSlide or 0) local y = 32 + (7 - h / 8) * 8 local reveal = self.picReveal local off = 0 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 9d4ba62f..02c6e0c5 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -316,6 +316,8 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) if selected then local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp)) local speed = px >= 27 and 5 or px >= 10 and 16 or 32 + -- engine/gfx/mon_icons.asm:24 + if PaletteFX.runsNotSGB() then speed = speed + 1 end alt = math.floor(counter / speed) % 2 == 1 end if alt and (name == "BALL" or name == "HELIX") then @@ -607,8 +609,10 @@ function PartyMenu:update(dt) self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message end - self.game.stack:pop() -- close the party menu + -- engine/menus/start_sub_menus.asm:144-151 + self.submenu = nil Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if self.game.stack:top() == self then self.game.stack:pop() end if ow then ow:flyTo(mapId) end end }) return @@ -730,6 +734,13 @@ function PartyMenu:update(dt) elseif action == "softboiled" then -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer -- 1/5 of the user's max HP to a chosen teammate + -- engine/menus/start_sub_menus.asm:236-258 + if mon.stats and mon.hp <= math.floor(mon.stats.hp / 5) then + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._NotHealthyEnoughText)) + return + end self.softboiledFrom = self.index elseif action == "escape" then -- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap, @@ -766,6 +777,16 @@ function PartyMenu:update(dt) self.game.save.player.name))) return -- .loop: submenu stays open behind the message end + if entry.move == "TELEPORT" then + -- engine/menus/start_sub_menus.asm:215-226 + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._WarpToLastPokemonCenterText, function() + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + end, { auto = { wait = false, delay = 60 } })) + return + end self.game.stack:pop() if ow then ow:beginTeleportOut() end return @@ -806,6 +827,8 @@ function PartyMenu:update(dt) return end if self.softboiledFrom then + -- engine/items/item_effects.asm:854-857 + if self.softboiledFrom == self.index then return end local user = party[self.softboiledFrom] self.softboiledFrom = nil self.game.overworld:useSoftboiledFieldMove(user, mon) diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 547fd873..1c33c840 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -60,6 +60,7 @@ end -- cb(qty) runs only on confirm. local function askQuantity(game, list, count, id, cb) local def = game.data.items[id] + list.pcQtyBox = nil if (def and def.keyItem) or id:find("^HM_") then cb(1) return @@ -67,12 +68,20 @@ local function askQuantity(game, list, count, id, cb) local prompt = list.footer list.footer = Strings("How many?") local QuantityBox = require("src.ui.QuantityBox") - game.stack:push(QuantityBox.new(game, { + local box + box = QuantityBox.new(game, { max = count, onDone = function(qty) - if qty then cb(qty) else list.footer = prompt end + if qty then + -- engine/menus/players_pc.asm:136-137 + list.pcQtyBox = box + cb(qty) + else + list.footer = prompt + end end, - })) + }) + game.stack:push(box) end local function refreshRows(list, items) @@ -90,6 +99,7 @@ local function pcList(game, items, opts) local draw = list.draw list.draw = function(self) draw(self) + if self.pcCompletion and self.pcQtyBox then self.pcQtyBox:draw() end if self.pcCompletion and self.pcCompletionBlink % 60 < 30 then Font.drawCode(Theme.moreArrow, 144, 128) @@ -103,6 +113,7 @@ local function pcList(game, items, opts) if input:wasPressed("a") or input:wasPressed("b") then Sound.playPress(game.data) self.pcCompletion = nil + self.pcQtyBox = nil local after = self.pcAfter self.pcAfter = nil if after then after() end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index ff6cfe96..f554696d 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -47,6 +47,12 @@ local function backToMenu(game, menu) end) end +-- engine/events/pokemart.asm:65 +local function relist(list, greet) + list.index = math.max(1, math.min(list.scroll + 1, #list.items)) + list.footer = greet +end + -- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206 local function refuse(game, menu, list, text, under) game.stack:push(TextBox.new(game, text, function() @@ -104,7 +110,7 @@ local function buy(game, stock, menu) onDone = function(qty) if not qty then dropQty() - list.footer = greet + relist(list, greet) return end local cost = qty * def.price @@ -116,7 +122,7 @@ local function buy(game, stock, menu) { choice = function(yes) if not yes then dropQty() - list.footer = greet + relist(list, greet) return end if game.save.money < cost then @@ -134,7 +140,7 @@ local function buy(game, stock, menu) Strings("Here you are!\nThank you!")), function() dropQty() - list.footer = greet + relist(list, greet) end, { preSound = function() return require("src.core.Sound").play(game.data, "Purchase") @@ -238,7 +244,7 @@ local function sell(game, menu) onDone = function(qty) if not qty then dropQty() - list.footer = greet + relist(list, greet) return end -- engine/events/pokemart.asm:84-91 @@ -249,7 +255,7 @@ local function sell(game, menu) { choice = function(yes) dropQty() if not yes then - list.footer = greet + relist(list, greet) return end game.save.money = game.save.money + unit * qty @@ -262,9 +268,8 @@ local function sell(game, menu) list.index, list.scroll = 1, 0 end list.items = fresh - list.index = math.max(1, math.min(list.index, #list.items)) -- a sale prints nothing -- engine/events/pokemart.asm:112 - list.footer = greet + relist(list, greet) end })) end, }) diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index f8269642..fa05511c 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -358,8 +358,9 @@ function SlotMachine:resolveWin(win) -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- shows it in the box while the screen flashes. LinedUpText follows. self.yeah = (sym == "7") - -- constants/charmap.asm:19-20 - self.message = sym .. require("src.render.TextBox").strip( + -- engine/slots/slot_machine.asm:479-489 + self.winSymbol = sym + self.message = require("src.render.TextBox").strip( romText(self.game.data, "_LinedUpText", " lined up!\nScored %d coins!", pay)) -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not @@ -372,6 +373,7 @@ end function SlotMachine:resolveLose() -- NotThisTimeText, then MainSlotMachineLoop asks "One more go?" + self.winSymbol = nil self.message = "Not this time!" self.stage = "message" self.afterMessage = "onemore" @@ -384,6 +386,8 @@ function SlotMachine:enterBet() self.betIndex = 0 self.bet = 3 self.message = nil + self.winSymbol = nil + self.betPrompt = nil self.payoutDisplay = 0 end @@ -399,6 +403,7 @@ end -- delay then CloseTextDisplay), otherwise ask "One more go?". function SlotMachine:afterSpin() if coins(self) == 0 then + self.winSymbol = nil self.message = Strings("Darn!\nRan out of coins!") self.stage = "message" self.afterMessage = nil @@ -453,7 +458,10 @@ function SlotMachine:update(dt) elseif after == "onemore" then self:afterSpin() else + -- engine/slots/slot_machine.asm:106-108 + local prompt = self.message self:enterBet() -- NotEnoughCoinsSlotMachineText -> menu + self.betPrompt = prompt end end return @@ -634,7 +642,12 @@ end -- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus). function SlotMachine:drawBottom() local lines - if self.stage == "bet" then + if self.stage == "bet" and self.betPrompt then + lines = {} + for line in (self.betPrompt .. "\n"):gmatch("(.-)\n") do + if line ~= "" then lines[#lines + 1] = line end + end + elseif self.stage == "bet" then lines = { "Bet how many", "coins?" } elseif self.stage == "onemore" then lines = { "One more", "go?" } @@ -651,25 +664,39 @@ function SlotMachine:drawBottom() end if not lines then return end Font.drawBox(0, 12, 20, 6) + local firstX = 8 + local art = self.winSymbol and self.message and self.symbolImg + and self:loadArt() + local rect = art and art.symbols and art.symbols[self.winSymbol] + if rect and self.stage ~= "flash" then + love.graphics.setColor(1, 1, 1, 1) + self.winQuad = self.winQuad or love.graphics.newQuad(0, 0, 16, 16, + self.symbolImg:getDimensions()) + self.winQuad:setViewport(rect.x, rect.y, rect.w, rect.h, + self.symbolImg:getDimensions()) + love.graphics.draw(self.symbolImg, self.winQuad, 2 * 8, 13 * 8) + firstX = 5 * 8 + end love.graphics.setColor(0, 0, 0, 1) - Font.draw(lines[1] or "", 8, 14 * 8) + Font.draw(lines[1] or "", firstX, 14 * 8) Font.draw(lines[2] or "", 8, 16 * 8) if self.stage == "bet" then -- 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) + -- engine/slots/slot_machine.asm:150-153 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) + Font.draw("×2", 16 * 8, 14 * 8) + Font.draw("×1", 16 * 8, 16 * 8) + Font.drawCode(0xED, 15 * 8, (12 + 2 * self.betIndex) * 8) 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"), 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) + Font.draw(Strings("NO"), 16 * 8, 15 * 8) + Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 2)) * 8) end love.graphics.setColor(1, 1, 1, 1) end @@ -707,7 +734,8 @@ function SlotMachine:draw() love.graphics.rectangle("fill", 40, 8, 32, 8) love.graphics.rectangle("fill", 88, 8, 32, 8) love.graphics.setColor(0, 0, 0, 1) - Font.draw(("%4d"):format(math.min(9999, coins(self))), 40, 8) + -- engine/slots/slot_machine.asm:645-649 + Font.draw(("%04d"):format(math.min(9999, coins(self))), 40, 8) Font.draw(("%04d"):format(self.payoutDisplay or 0), 88, 8) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index bf08a1eb..9658d3ec 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -62,6 +62,8 @@ function StartMenu.new(game) -- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647) table.insert(items, { label = Strings("SAVE"), keepOpen = true, onSelect = function() + -- home/start_menu.asm:52-53 + if menu then menu.hollowIndex = menu.index end local TextBox = require("src.render.TextBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 @@ -95,7 +97,7 @@ function StartMenu.new(game) Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8) Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8) Font.draw(Strings("TIME"), 5 * 8, 8 * 8) - Font.draw(("%3d:%02d"):format(math.floor(t / 3600), + Font.draw(("%3d%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), 13 * 8, 8 * 8) love.graphics.setColor(1, 1, 1, 1) end, @@ -114,24 +116,32 @@ function StartMenu.new(game) choiceBox = Theme.saveBox, choice = function(yes) if not yes then closePanel() return end - -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." - -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, - -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. - -- Neither page takes a button press (#765); the second waits on - -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then - -- DelayFrames 30. The write itself is invisible either side of the - -- "Now saving..." hold, so it stays on that box's onDone. - game.stack:push(TextBox.new(game, Strings("Now saving..."), function() - game:writeSave() - game.stack:push(TextBox.new(game, - Strings("%s saved\nthe game!", game.save.player.name or "RED"), - closePanel, { auto = { - sound = function() - return require("src.core.Sound").play(game.data, "Save") - end, - delay = 30, - } })) - end, { auto = { delay = 120 } })) + local function doSave() + -- engine/menus/save.asm:164-181 + game.stack:push(TextBox.new(game, Strings("Now saving..."), function() + game:writeSave() + game.stack:push(TextBox.new(game, + Strings("%s saved\nthe game!", game.save.player.name or "RED"), + closePanel, { auto = { + sound = function() + return require("src.core.Sound").play(game.data, "Save") + end, + delay = 30, + } })) + end, { auto = { delay = 120 } })) + end + -- engine/menus/save.asm:156-164 + if not require("src.core.SaveData").previousFileDiffers(game.save) then + return doSave() + end + game.stack:push(TextBox.new(game, + game.data.text._OlderFileWillBeErasedText, nil, { + choiceBox = Theme.saveBox, + choice = function(ok) + if not ok then closePanel() return end + doSave() + end, + })) end, })) end @@ -190,7 +200,7 @@ function StartMenu.new(game) -- anchor keeps it flush when the view is zoomed out and the letterbox no -- longer fills the window { tx = 10, ty = 0, tw = 10, maxVisible = maxVisible, startCloses = true, - anchor = "topright" }) + anchor = "topright", noWrap = false }) -- the cursor position survives closing the menu -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) menu.index = math.min(game.startMenuIndex or 1, #items) diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 9ed0b99a..8110d07a 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -155,16 +155,19 @@ function SummaryMenu:draw() if self.sprite then local pw, ph = self.sprite:getDimensions() local py = math.max(0, 56 - ph) - love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1) + -- home/pics.asm:68-76 + local px = 8 + math.max(0, 56 - math.floor((8 - pw / 8) / 2) * 8 - pw) + love.graphics.draw(self.sprite, px + pw, py, 0, -1, 1) -- a full-color pic has to sit out the SGB monPal recolor, so mark the -- rect the mirrored draw covers for the unshaded pass (#430) if self.spriteTrueColor then - require("src.render.PaletteFX").markTrueColor(8, py, pw, ph) + require("src.render.PaletteFX").markTrueColor(px, py, pw, ph) end end local HudTiles = require("src.render.HudTiles") love.graphics.setColor(0, 0, 0, 1) - Font.draw(mon.nickname or def.name, 72, 8) + -- engine/pokemon/status_screen.asm:414-420 + Font.draw(self.page == 2 and def.name or (mon.nickname or def.name), 72, 8) -- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write -- the single-tile '№' at (1,7) and '' at (2,7); :143-146 then -- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7). @@ -251,12 +254,15 @@ function SummaryMenu:draw() if mv then local mdef = data.moves[mv.id] Font.draw(mdef.name, 16, y) - Font.draw(Strings("PP"), 88, y + 8) - local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5) + -- engine/pokemon/status_screen.asm:321-324 + HudTiles.statusTile(0x72, 88, y + 8) + HudTiles.statusTile(0x72, 96, y + 8) + local maxPP = require("src.pokemon.Pokemon").maxPP(mdef.pp, mv.ppUps) Font.draw(("%2d/%2d"):format(mv.pp, maxPP), 112, y + 8) else Font.draw("-", 16, y) - Font.draw("--", 112, y + 8) + -- engine/pokemon/status_screen.asm:325-330 + Font.draw("--", 88, y + 8) end end end diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 442ffd6b..10024d55 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -88,7 +88,6 @@ local PIKA_X = 68 -- Fixed screen X while riding (center 80, 24px p local TOTAL_SECTIONS = 24 -- wSurfingMinigameDistance byte 0 reaches $18 (24) local SECTION_ACC_MAX = 65536 -- 16-bit distance acc overflow (256px per section in 8.8 fixed) local SECTION_PX = 256 -- pixels advanced per section (65536 >> 8) -local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) local COAST_SPEED_FIXED = 9 * 256 -- SurfingMinigame_CoastAfterGoal: 9.0 px/frame local BG_SCROLL_STEP = 384 -- ScrollAndGenerateBGMap: 1.5 px/frame (8.8 fixed) local RDIV_PER_FRAME = 17 -- rDIV @ 16384 Hz ≈ 273 ticks per 59.7275 Hz frame (mod 256) @@ -1531,24 +1530,25 @@ end -- Draw HUD status bar with inverted progress marker (moving right-to-left towards beach) function SurfingMinigame:drawHUD() - -- White background in bottom 16 rows love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16) + -- engine/minigame/surfing_pikachu.asm:247 + local wy = 126 + love.graphics.rectangle("fill", 0, wy, 160, 144 - wy) -- Window tilemap (SurfingPikachuMinigame_DrawStaticTilemapLayout): -- row 0 cols 1-2: $15,$16 island; row 1 cols 1-9: $17,$18,$19×7; -- row 1 cols 12-13: $1b,$1c "HP:"; digits are OAM sprites at X=$80. if self.bg and self.tq then - love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT) - love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT) + love.graphics.draw(self.bg, self.tq[0x15], 8, wy) + love.graphics.draw(self.bg, self.tq[0x16], 16, wy) - love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8) - love.graphics.draw(self.bg, self.tq[0x18], 16, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x17], 8, wy + 8) + love.graphics.draw(self.bg, self.tq[0x18], 16, wy + 8) for i = 1, 7 do - love.graphics.draw(self.bg, self.tq[0x19], 16 + i * 8, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x19], 16 + i * 8, wy + 8) end - love.graphics.draw(self.bg, self.tq[0x1b], 96, BG_HEIGHT + 8) - love.graphics.draw(self.bg, self.tq[0x1c], 104, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x1b], 96, wy + 8) + love.graphics.draw(self.bg, self.tq[0x1c], 104, wy + 8) end -- Mini-Pikachu progress marker (tile $fe) @@ -1556,17 +1556,17 @@ function SurfingMinigame:drawHUD() local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset local markerX = markerStartX - (self.distanceSection or 0) * 2 if self.ob and self.oq and self.oq[0xfe] then - love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) + love.graphics.draw(self.ob, self.oq[0xfe], markerX, 134) end - -- 4 HP countdown digits (OAM X $80..$98) + -- engine/minigame/surfing_pikachu.asm:294 local s = string.format("%04d", math.max(0, math.floor(self.hp))) for i = 1, 4 do local d = tonumber(s:sub(i, i)) or 0 if self.ob and self.oq and self.oq[0xd0 + d] then - love.graphics.draw(self.ob, self.oq[0xd0 + d], 128 + (i - 1) * 8, BG_HEIGHT + 8) + love.graphics.draw(self.ob, self.oq[0xd0 + d], 120 + (i - 1) * 8, 135) else - Font.draw(tostring(d), 128 + (i - 1) * 8, BG_HEIGHT + 8) + Font.draw(tostring(d), 120 + (i - 1) * 8, 135) end end end diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index a76f8cc3..72d0a05b 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -533,7 +533,7 @@ function ContinueInfo:draw() Font.draw(("%3d"):format(owned), 16 * 8, 13 * 8) local t = math.floor(save.playTime or 0) Font.draw(Strings("TIME"), 40, 120) - Font.draw(("%3d:%02d"):format(math.floor(t / 3600), + Font.draw(("%3d%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), 104, 120) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 1ded53e3..4e252d1d 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -817,14 +817,35 @@ end function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) Font.drawBox(4, boxTy, 12, 8) local y0 = boxTy * 8 - local no = Strings("No.%03d", dexOf(self.game, mon)) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 56, y0, Font.width(no), 8) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(no, 56, y0) + local HudTiles = require("src.render.HudTiles") + local dex = dexOf(self.game, mon) + -- engine/movie/trade2.asm:1-21 + if Strings.lookup("No.%03d") == "No.%03d" then + local no = ("%03d"):format(dex) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 56, y0, 16 + Font.width(no), 8) + HudTiles.statusTile(0x74, 56, y0) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(0xF2, 64, y0) + Font.draw(no, 72, y0) + else + local no = Strings("No.%03d", dex) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 56, y0, Font.width(no), 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(no, 56, y0) + end Font.draw(speciesName(self.game, mon), 40, y0 + 16) Font.draw(Strings("OT/%s", ot or "????"), 40, y0 + 32) - Font.draw(Strings("IDNo.%05d", otId or 0), 40, y0 + 48) + if Strings.lookup("IDNo.%05d") == "IDNo.%05d" then + HudTiles.statusTile(0x73, 40, y0 + 48) + HudTiles.statusTile(0x74, 48, y0 + 48) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(0xF2, 56, y0 + 48) + Font.draw(("%05d"):format(otId or 0), 64, y0 + 48) + else + Font.draw(Strings("IDNo.%05d", otId or 0), 40, y0 + 48) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 629a1085..3cfe04dc 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -149,8 +149,8 @@ function TrainerCard:draw() Font.draw(Strings("MONEY/¥%d", save.money or 0), 16, 32) local t = math.floor(save.playTime or 0) -- home/print_num.asm:217 - Font.draw(Strings("TIME/ %d:%02d", math.floor(t / 3600), - math.floor(t / 60) % 60), 16, 48) + Font.draw((Strings("TIME/ %d:%02d", math.floor(t / 3600), + math.floor(t / 60) % 60):gsub("(%d):(%d%d)$", "%1%2")), 16, 48) -- the circle-dotted BADGES banner (TrainerInfo_BadgesText) -- engine/menus/start_sub_menus.asm:544 diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 1bd193d4..41e7f762 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -25,7 +25,8 @@ local PALETTES = { }, -- Pokemon Tower / Agatha, then the caves byTileset = { CEMETERY = "GRAYMON", CAVERN = "CAVE" }, - byPrefix = { { prefix = "ROUTE_", palette = "ROUTE" } }, + -- engine/gfx/palettes.asm:147-149 + byPrefix = { { pattern = "^ROUTE_%d+$", palette = "ROUTE" } }, default = "ROUTE", } diff --git a/src/world/Map.lua b/src/world/Map.lua index d0852b7f..51619b04 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -233,6 +233,13 @@ function Map:isGrassCell(cx, cy) return grass ~= nil and self:cellTile(cx, cy) == grass end +-- engine/overworld/movement.asm:526-551 +function Map:isNpcGrassCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + local grass = self.tileset.grassTile + return grass ~= nil and self:tileAt(cx * 2 + 1, cy * 2) == grass +end + -- Water and eastern-shore tiles, from the tileset's waterTiles/shoreTiles -- (hash sets built in Map.new). Tileset membership in water_tilesets.asm -- is checked by the caller. diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6bbe0036..3fa41005 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -976,7 +976,11 @@ local function paletteLookup(palettes, mapId, tileset) local byTileset = palettes.byTileset if byTileset and tileset and byTileset[tileset] then return byTileset[tileset] end for _, row in ipairs(palettes.byPrefix or {}) do - if row.prefix and mapId:find(row.prefix, 1, true) == 1 then return row.palette end + if row.pattern then + if mapId:find(row.pattern) then return row.palette end + elseif row.prefix and mapId:find(row.prefix, 1, true) == 1 then + return row.palette + end end return nil end @@ -1152,18 +1156,21 @@ end -- Field-item entry points keep presentation and state transitions in the -- owning world instead of asking a supported facade to reproduce either one. function OverworldState:useBicycle() - local name = Game.save.player.name + local t = Game.data.text + Game.stringBuffer = Game.data.items.BICYCLE.name if Game.save.onBike then if Game.save.forcedBike then return false end Game.save.onBike = false require("src.core.Music").playMap(Game.data, self.map.id, false) + -- engine/items/item_effects.asm:2365-2369 Game.stack:push(TextBox.new(Game, - Strings("%s got off\nthe BICYCLE.", name))) + TextBox.strip(t._GotOffBicycleText1) .. "\n" .. t._GotOffBicycleText2)) elseif self:bikeAllowed(self.map.id) and not self.player.surfing then Game.save.onBike = true require("src.core.Music").playMap(Game.data, self.map.id, true) + -- engine/items/item_effects.asm:2359-2363 Game.stack:push(TextBox.new(Game, - Strings("%s got on\nthe BICYCLE!", name))) + TextBox.strip(t._GotOnBicycleText1) .. "\n" .. t._GotOnBicycleText2)) else return false end @@ -1296,10 +1303,29 @@ end -- engine/battle/battle_transitions.asm:28 function OverworldState:oamCulled(e) - if self.battleOamKeep == nil then return false end + if self.battleOamKeep == nil then return OverworldState.spriteOutOfView(self, e) end return e ~= self.player and e ~= self.battleOamKeep end +-- engine/overworld/movement.asm:489-509 +function OverworldState:spriteOutOfView(e) + local p = self.player + if e == p or not (p and p.cellX and e.cellX) then return false end + if GameVersion.generation() ~= 1 then return false end + if self.marchers and self.marchers[e] then return false end + for _, mv in ipairs(self.scriptMoves or {}) do + if mv.entity == e then return false end + end + local r = Game and Game.renderer + local vw, vh = 160, 144 + if r and r.worldViewSize then vw, vh = r:worldViewSize() end + if vw > 160 or vh > 144 then return false end + local x = e.moving and e.targetX or e.cellX + local y = e.moving and e.targetY or e.cellY + local dx, dy = x - p.cellX, y - p.cellY + return dy < -4 or dy > 4 or dx < -4 or dx > 5 +end + -- ------------------------------------------------------------------------- -- update -- ------------------------------------------------------------------------- @@ -1853,7 +1879,9 @@ function OverworldState:handleInput() -- extra check passes (CheckWarpsCollision: route-gate doorways, dock -- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue -- #230), which the map-edge path guards the same way. - if result == "blocked" and self:canCollisionWarp() then + -- home/overworld.asm:260 + if result == "blocked" and self:canCollisionWarp() + and not self.player.surfing then local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, self.player.cellX, self.player.cellY, dir) if w then @@ -2169,6 +2197,13 @@ function OverworldState:checkEdgeExit(dir) -- of bouncing floors (issue #230), while the door mat you warped in on -- keeps it and exits on that same tile (issue #378). if not self:canCollisionWarp() then return false end + -- home/overworld.asm:260 + if p.surfing then return false end + -- home/overworld.asm:254 + if not Warp.extraCheck(self.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, dir) then + return false + end self:takeWarp(w.def) return true end @@ -2879,6 +2914,9 @@ function OverworldState:tryHiddenObject(fx, fy) elseif h.state == "keys" then Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText or romText(Game.data, "_GameCornerSomeonesKeysText", "Someone's keys!\nThey'll be back."))) + elseif self.player.facing ~= "left" and self.player.facing ~= "right" then + -- engine/slots/game_corner_slots2.asm:2-4 + return true elseif not save.inventory.COIN_CASE then Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText or romText(Game.data, "_GameCornerCoinCaseText", "A COIN CASE is\nrequired!"))) @@ -3231,12 +3269,15 @@ function OverworldState:billsHousePokemonList() end, }) end - table.insert(items, { label = Strings("CANCEL") }) + local function closeHeld() Game.stack:pop() end + table.insert(items, { label = Strings("CANCEL"), onSelect = closeHeld }) -- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12 local menu = Menu.new(Game, items, - { tx = 0, ty = 0, tw = 11, th = 12 }) + { tx = 0, ty = 0, tw = 11, th = 12, onCancel = closeHeld }) menu.isMenu = true - Game.stack:push(menu) + -- engine/events/hidden_events/bills_house_pc.asm:94-95 + Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText2, nil, + { stay = { onShown = function() Game.stack:push(menu) end } })) end Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1 or Strings("BILL's favorite\nPOKéMON list!"), openList)) @@ -3786,7 +3827,11 @@ function OverworldState:openPC(onDone) -- them, then translate any source labels it chose to retain. local translatedLabels = { [boxPcLabel] = function() - return metBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC") + -- engine/pokemon/bills_pc.asm:88-89 + local source = metBill and "BILL'S PC" or "SOMEONE'S PC" + local label = metBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC") + if label == source then return (source:gsub("'S PC$", "'s PC")) end + return label end, [playerPcLabel] = function() return Strings("%s's PC", playerName) end, ["PROF.OAK's PC"] = function() return Strings("PROF.OAK's PC") end, @@ -4030,19 +4075,17 @@ function OverworldState:nurseHeal(onDone, npc) turnLeft() return end + if not pika then + turnLeft() + return + end + -- pokeyellow engine/events/pokecenter.asm:43 + Follower.setVisible(self, false) + -- pokeyellow engine/events/pokecenter.asm:100 + if npc then npc.facing = "up" end + hold(64, turnLeft) -- pokeyellow engine/events/pokecenter.asm:39 - hold(64, function() - if not pika then - turnLeft() - return - end - -- pokeyellow engine/events/pokecenter.asm:43 - Follower.setVisible(self, false) - -- pokeyellow engine/events/pokecenter.asm:100 - if npc then npc.facing = "up" end - hold(64, turnLeft) - end) - end)) + end, { auto = { delay = GameVersion.isYellow() and 64 or 0 } })) end) end, choiceLabels = { Strings("HEAL"), Strings("CANCEL") }, choiceBox = Theme.healCancelBox })) @@ -4734,8 +4777,11 @@ function OverworldState:applyFieldPoison() return end if not alive then + -- home/text_script.asm:197 Game.stack:push(TextBox.new(Game, - Strings("%s blacked\nout!", save.player.name), function() + romText(Game.data, "_PlayerBlackedOutText", + "%s is out of\nuseable POKéMON!\f%s blacked\nout!", + save.player.name, save.player.name), function() local Pokemon = require("src.pokemon.Pokemon") for _, mon in ipairs(save.party) do Pokemon.heal(mon) end save.money = math.floor(save.money @@ -4744,7 +4790,7 @@ function OverworldState:applyFieldPoison() local landing = { map = lm, x = lx, y = ly } Runtime.emit("world.blacked_out", { save = save, healTarget = self:healPoint(), landing = landing }) - self:warpToHealPoint(nil, { landing = landing }) + self:warpToHealPoint(nil, { landing = landing, keepFacing = true }) end)) end end @@ -4903,6 +4949,11 @@ function OverworldState:onStepComplete() end end + -- home/overworld.asm:369 + if hooks and hooks.noBattles and hooks.noBattles(Game, self, p.cellX, p.cellY) then + return + end + if Game.save.repelSteps and Game.save.repelSteps > 0 then Game.save.repelSteps = Game.save.repelSteps - 1 if Game.save.repelSteps == 0 then @@ -5306,8 +5357,11 @@ end function OverworldState:safariStep() local st = Game.save.safari if not st or not self:inSafariStepZone() then return false end - st.steps = st.steps - 1 - if st.steps > 0 then return false end + -- engine/events/hidden_events/safari_game.asm:14-24 + if st.steps > 0 then + st.steps = st.steps - 1 + return false + end self:safariGameOver(romText(Game.data, "_TimesUpText", "PA: Ding-dong!\nTime's up!")) return true end @@ -5318,8 +5372,9 @@ function OverworldState:safariGameOver(text) -- SetEvent EVENT_SAFARI_GAME_OVER -- engine/events/hidden_events/safari_game.asm:54 Game.save.safariGameOver = true local t = Game.data.text + local over = t._GameOverText or romText(Game.data, "_GameOverText", "PA: Your SAFARI\nGAME is over!") Game.stack:push(TextBox.new(Game, - (text or "") .. "\f" .. (t._GameOverText or romText(Game.data, "_GameOverText", "PA: Your SAFARI\nGAME is over!")), + text and (text .. "\f" .. over) or over, function() local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp") -- home/overworld.asm:54 @@ -5361,15 +5416,15 @@ function OverworldState:afterBattle(result, battle) local landing = { map = lm, x = lx, y = ly } Runtime.emit("world.blacked_out", { save = Game.save, healTarget = self:healPoint(), landing = landing }) - self:warpToHealPoint(nil, { landing = landing }) + self:warpToHealPoint(nil, { landing = landing, keepFacing = true }) else -- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which -- re-runs the floor's door callback: beating the last Rocket Hideout guard -- opens the lift gate without leaving the map (#372) self:stampClosedDoors() - -- throwing the last SAFARI BALL ends the game + -- engine/events/hidden_events/safari_game.asm:64-71 if Game.save.safari and Game.save.safari.balls <= 0 then - self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!")) + self:safariGameOver() end end end @@ -5548,7 +5603,9 @@ function OverworldState:warpToHealPoint(onDone, opts) else map, x, y = self:escapeWarpTarget() end - self:startWarpTo(map, x, y, "down", onDone, { healWarp = true }) + -- home/overworld.asm:756 + local facing = opts and opts.keepFacing and self.player.facing or "down" + self:startWarpTo(map, x, y, facing, onDone, { healWarp = true }) -- PrepareForSpecialWarp (engine/overworld/special_warps.asm:1-29) writes the self:rememberOutdoor(map, x, y) end @@ -5619,7 +5676,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) framesIn = Timing.FADE_IN_FROM_WHITE, } or nil -- home/overworld.asm:681 - if not specialWarp and self.dark then fadeOpts = { frames = 0 } end + if not specialWarp and (self.dark or Game.blackoutFaded) then + fadeOpts = { frames = 0 } + end + Game.blackoutFaded = nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new @@ -6426,10 +6486,14 @@ end -- engine/overworld/movement.asm:90 local function grassStrip(self, e) local map = self.map + local test = map.isGrassCell + if e ~= self.player and GameVersion.generation() == 1 and map.isNpcGrassCell then + test = map.isNpcGrassCell + end local inGrass = (e.cellX ~= nil and e.cellY ~= nil - and map:isGrassCell(e.cellX, e.cellY)) + and test(map, e.cellX, e.cellY)) or (e.targetX ~= nil and e.targetY ~= nil - and map:isGrassCell(e.targetX, e.targetY)) + and test(map, e.targetX, e.targetY)) if not inGrass then return nil end local sprite, px, py = nil, e.px, e.py if e.pose then sprite, px, py = e:pose() end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index db4d1a7a..d55d0ccf 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -54,12 +54,11 @@ local HAPPINESS_CHANGES = { TRADE = { -10, -10, -20, mood = 0x00 }, } --- the companion mon: a healthy (or any) party PIKACHU stands in for the --- original's OT-checked starter, same approximation as shouldSpawn +-- engine/pikachu/pikachu_status.asm:1 function PikachuFollower.starterInParty(save, needHealthy) for _, mon in ipairs(save.party or {}) do - if mon.species == "PIKACHU" - and (not needHealthy or (mon.hp or 0) > 0) then + if PikachuFollower.isStarterPikachu(save, mon) then + if needHealthy and (mon.hp or 0) <= 0 then return nil end return mon end end @@ -154,10 +153,7 @@ function PikachuFollower.onMoveLearned(save, mon, moveId) save.pikachuMood = 0x85 end --- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a --- healthy Pikachu is in the party (the original checks the starter's OT --- identity; a traded second Pikachu standing in is accepted here). --- Surfing and biking hide the follower (BIT_PIKACHU_SPAWN flags). +-- engine/pikachu/pikachu_follow.asm:9 local function shouldSpawn(game, ow) if not GameVersion.isYellow() then return false end local save = game.save @@ -173,10 +169,7 @@ local function shouldSpawn(game, ow) if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then return false end - for _, mon in ipairs(save.party or {}) do - if mon.species == "PIKACHU" and (mon.hp or 0) > 0 then return true end - end - return false + return PikachuFollower.starterInParty(save, true) ~= nil end function PikachuFollower.setShouldSpawn(fn) diff --git a/tests/drivers/em_birch_rescue_speed_2912.lua b/tests/drivers/em_birch_rescue_speed_2912.lua new file mode 100644 index 00000000..5bccafa2 --- /dev/null +++ b/tests/drivers/em_birch_rescue_speed_2912.lua @@ -0,0 +1,103 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_birch_rescue_speed_2912" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + local function finish() + print(string.format("[driver] em_birch_rescue_speed_2912: %d failure(s)", fails)) + love.event.quit(fails == 0 and 0 or 1) + U.wait(10) + end + + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(30) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Field = require("src.core.game3.field") + local Message = require("src.ui.game3.message") + local VARS = Flags.forVersion("emerald").VAR_IDS + result(Runtime.getSession() ~= nil, "field session exists") + if not Runtime.getSession() then return finish() end + + local steps = 0 + local origFixed = game.fixedUpdate + game.fixedUpdate = function(self, dt) steps = steps + 1; return origFixed(self, dt) end + + game.speedOverride = nil + game.options.speedOverworld, game.options.speedBattle, game.options.speedMenu = 4, 1, 1 + result(game:logicSpeed() == 4, "OVERWORLD SPEED 4X is the logic speed (" .. tostring(game:logicSpeed()) .. ")") + + -- data/maps/LittlerootTown/scripts.inc:8 + Flags.setVar(Space.store, nil, VARS.VAR_LITTLEROOT_TOWN_STATE, 1) + Flags.setVar(Space.store, nil, VARS.VAR_ROUTE101_STATE, 0) + Map.load(nil, game, "EM_LITTLEROOT_TOWN", { x = 11, y = 3, facing = "up" }) + local s = Runtime.getSession() + s.x, s.y, s.facing = 11, 3, "up" + Player.cellX, Player.cellY, Player.px, Player.py = 11, 3, 11 * 16, 3 * 16 + Player.targetX, Player.targetY, Player.facing = 11, 3, "up" + U.wait(40) + + local rescue = Space.scriptKey("Route101_EventScript_StartBirchRescue") + local function running() return Space.vm and Space.vm:isRunning() and Space.vm._scriptKey or nil end + local function pressA() table.insert(game.input.pressQueue, "a") end + + steps = 0 + local hist, total = {}, 0 + local function sample() + total = total + 1 + hist[steps] = (hist[steps] or 0) + 1 + steps = 0 + end + local started + for i = 1, 600 do + if Message.isOpen and Message.isOpen() and i % 10 == 0 then + pressA() + else + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + end + coroutine.yield() + game.input.state.up = false + sample() + if running() == rescue then started = true break end + end + result(started == true, "walking north from Littleroot starts Route101_EventScript_StartBirchRescue") + if not started then return finish() end + U.still(game, DIR .. "/2912_01_help_me.png") + + hist, total = {}, 0 + local chaseShot = false + local ran = 0 + for i = 1, 4000 do + if i % 12 == 0 then pressA() end + coroutine.yield() + sample() + ran = i + if not chaseShot and i > 60 and Field.isLocked() and not (Message.isOpen and Message.isOpen()) then + chaseShot = true + U.still(game, DIR .. "/2912_02_zigzagoon_chase.png") + end + if not Field.isLocked() and not running() then break end + end + local at4 = hist[4] or 0 + local keys = {} + for k in pairs(hist) do keys[#keys + 1] = k end + table.sort(keys) + for _, k in ipairs(keys) do print(string.format("[driver] steps=%d updates=%d", k, hist[k])) end + result(ran < 4000, "rescue script finished (" .. ran .. " updates)") + result(total > 0 and at4 >= total * 0.95, string.format("rescue ran at 4 logic steps per update (%d/%d)", at4, total)) + result(Flags.getVar(Space.store, nil, VARS.VAR_ROUTE101_STATE) == 2, "VAR_ROUTE101_STATE is 2 after the scene") + U.still(game, DIR .. "/2912_03_after_rescue.png") + return finish() +end diff --git a/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua index adf2aa36..5fa2100a 100644 --- a/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua +++ b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua @@ -369,7 +369,7 @@ return function(game) -- the box PC reads SOMEONE'S PC until EVENT_MET_BILL (pokemon_pc.asm) local flags = game.save.flags or {} local boxPC = (flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET) - and "BILL'S PC" or Strings("SOMEONE'S PC") + and "BILL's PC" or Strings("SOMEONE's PC") if topIs(Menu) then mark = #cues choose(game.stack:top(), boxPC) diff --git a/tests/drivers/ui_tiles_2944.lua b/tests/drivers/ui_tiles_2944.lua new file mode 100644 index 00000000..39054538 --- /dev/null +++ b/tests/drivers/ui_tiles_2944.lua @@ -0,0 +1,48 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOTS = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local fails = 0 + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local mon = Pokemon.new(game.data, "NIDORINA", 20) + mon.nickname = "TERRY" + game.save.party[1] = mon + game.save.party[2] = Pokemon.new(game.data, "PIKACHU", 100) + + local summary = Screens.push(game, "SummaryMenu", mon) + U.wait(30) + U.still(game, SHOTS .. "/2944_01_status_page1.png") + summary.page = 2 + U.wait(4) + U.still(game, SHOTS .. "/2963_02_status_page2_species.png") + check(summary.page == 2, "status page 2 open") + while game.stack:top() ~= game.overworld do game.stack:pop() end + + Screens.push(game, "SummaryMenu", game.save.party[2]) + U.wait(30) + U.still(game, SHOTS .. "/2945_03_status_pikachu_pic.png") + while game.stack:top() ~= game.overworld do game.stack:pop() end + + local DexEntryMenu = require("src.ui.DexEntryMenu") + game.stack:push(DexEntryMenu.new(game, { species = "KOFFING", forceOwned = true })) + U.wait(90) + U.still(game, SHOTS .. "/2944_04_dex_no_dot_koffing.png") + while game.stack:top() ~= game.overworld do game.stack:pop() end + + local Slot = require("src.ui.SlotMachine") + game.save.coins = 297 + local slots = Slot.new(game, false) + game.stack:push(slots) + U.wait(20) + U.still(game, SHOTS .. "/2938_05_slots_bet_credit.png") + check(slots.stage == "bet", "slot machine opens on the bet menu") + while game.stack:top() ~= game.overworld do game.stack:pop() end + + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/engine/bag_bicycle_no_options_bug1705.lua b/tests/engine/bag_bicycle_no_options_bug1705.lua index 772144aa..2e32991d 100644 --- a/tests/engine/bag_bicycle_no_options_bug1705.lua +++ b/tests/engine/bag_bicycle_no_options_bug1705.lua @@ -40,6 +40,11 @@ local Data = Fixtures.fresh() -- name/keyItem/machine off a def; the branches under test key on the id Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0, keyItem = true } +Data.text = Data.text or {} +Data.text._GotOnBicycleText1 = "{PLAYER} got on the{DONE}" +Data.text._GotOnBicycleText2 = "{RAM:wStringBuffer}!{PROMPT}" +Data.text._GotOffBicycleText1 = "{PLAYER} got off{DONE}" +Data.text._GotOffBicycleText2 = "the {RAM:wStringBuffer}.{PROMPT}" Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP", price = 0, keyItem = true } Data.items.FIX_POTION = Data.items.FIX_POTION diff --git a/tests/engine/bag_split_stack_bug2671.lua b/tests/engine/bag_split_stack_bug2671.lua index ec3ba999..55607e82 100644 --- a/tests/engine/bag_split_stack_bug2671.lua +++ b/tests/engine/bag_split_stack_bug2671.lua @@ -291,7 +291,7 @@ do list.scroll = 1 sellRow(3, 1) eq(list.scroll, 1, "selling part of a row keeps the list scroll") - eq(list.index, 3, "and the cursor") + eq(list.index, 2, "and the cursor goes to the top visible row") sellRow(3, 1) eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the emptied row is gone") eq(list.scroll, 0, "an emptied row resets the sell list scroll") diff --git a/tests/engine/bag_use_toss_hollow_cursor_2063.lua b/tests/engine/bag_use_toss_hollow_cursor_2063.lua index 8c8d1983..74084695 100644 --- a/tests/engine/bag_use_toss_hollow_cursor_2063.lua +++ b/tests/engine/bag_use_toss_hollow_cursor_2063.lua @@ -26,6 +26,11 @@ local Bag = require("src.inventory.Bag") local Data = Fixtures.fresh() Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0, keyItem = true } +Data.text = Data.text or {} +Data.text._GotOnBicycleText1 = "{PLAYER} got on the{DONE}" +Data.text._GotOnBicycleText2 = "{RAM:wStringBuffer}!{PROMPT}" +Data.text._GotOffBicycleText1 = "{PLAYER} got off{DONE}" +Data.text._GotOffBicycleText2 = "the {RAM:wStringBuffer}.{PROMPT}" Data.items.FIX_POTION = Data.items.FIX_POTION or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 } local tmMove = next(Data.moves) diff --git a/tests/engine/battle_arrow_cell_bug2674.lua b/tests/engine/battle_arrow_cell_bug2674.lua index 44cc2e12..6c241d7a 100644 --- a/tests/engine/battle_arrow_cell_bug2674.lua +++ b/tests/engine/battle_arrow_cell_bug2674.lua @@ -56,12 +56,14 @@ local function drawnAt(battle, frame) local ok, err = pcall(function() battle:drawTextArea() end) Font.drawCode, Font.drawBox = real, realBox T.check(ok, "drawTextArea runs (" .. tostring(err) .. ")") - local at, row = {}, 0 + local at, row, arrow = {}, 0, false for _, h in ipairs(hits) do if h.y == 128 then row = row + 1 end - if h.x == 144 and h.y == 128 then at[#at + 1] = h.code end + if h.x == 144 and h.y == 128 then + if h.code == 0xEE then arrow = true else at[#at + 1] = h.code end + end end - return at, row + return at, row, arrow end local TEXT = "YOUR DEX IS\nENTIRELY COMPLETEZ\vCONGRATULATIONSZ" @@ -74,9 +76,10 @@ do T.eq(battle.shown[2] and battle.shown[2][18], LAST, "red bottom line's 18th glyph is typed") battle.msgPreWait = 0 - local lit, row = drawnAt(battle, 0) + local lit, row, arrow = drawnAt(battle, 0) T.check(row >= 17, "the bottom line is drawn") T.eq(#lit, 0, "red arrow replaces the 18th glyph while lit") + T.check(arrow, "the arrow sits in tile (18,16), home/text.asm:440") local dark, rowDark = drawnAt(battle, 30) T.check(rowDark >= 17, "the bottom line is drawn on blink-off") T.eq(#dark, 0, "red blink-off shows a blank cell, not the glyph") diff --git a/tests/engine/battle_transition_oam_bug1948.lua b/tests/engine/battle_transition_oam_bug1948.lua index da29beb7..c31dc63c 100644 --- a/tests/engine/battle_transition_oam_bug1948.lua +++ b/tests/engine/battle_transition_oam_bug1948.lua @@ -30,6 +30,30 @@ do check(OW.oamCulled(ow, bystander) == true, "trainer battle clears bystanders") end +do + -- engine/overworld/movement.asm:489-509 + local me = { cellX = 10, cellY = 9 } + local ow = { player = me, scriptMoves = {} } + local function at(x, y, extra) + local e = { cellX = x, cellY = y } + for k, v in pairs(extra or {}) do e[k] = v end + return e + end + check(OW.oamCulled(ow, at(10, 13)) == false, "an NPC 4 rows below draws") + check(OW.oamCulled(ow, at(10, 14)) == true, "an NPC 5 rows below is hidden") + check(OW.oamCulled(ow, at(10, 5)) == false, "an NPC 4 rows above draws") + check(OW.oamCulled(ow, at(10, 4)) == true, "an NPC 5 rows above is hidden") + check(OW.oamCulled(ow, at(15, 9)) == false, "an NPC 5 columns right draws") + check(OW.oamCulled(ow, at(16, 9)) == true, "an NPC 6 columns right is hidden") + check(OW.oamCulled(ow, at(6, 9)) == false, "an NPC 4 columns left draws") + check(OW.oamCulled(ow, at(5, 9)) == true, "an NPC 5 columns left is hidden") + check(OW.oamCulled(ow, at(10, 14, { moving = true, targetX = 10, targetY = 13 })) + == false, "a walking NPC is placed by its destination cell") + local walker = at(10, 14) + ow.scriptMoves = { { entity = walker } } + check(OW.oamCulled(ow, walker) == false, "a scripted walker skips the view test") +end + do local got local ctx = { npc = trainer, overworld = { diff --git a/tests/engine/caught_ball_obp_restore_bug2127.lua b/tests/engine/caught_ball_obp_restore_bug2127.lua index fb9520b7..c6900269 100644 --- a/tests/engine/caught_ball_obp_restore_bug2127.lua +++ b/tests/engine/caught_ball_obp_restore_bug2127.lua @@ -77,6 +77,13 @@ for _, v in ipairs({ { "yellow", "ogred" }, { "red", "gbc" }, { "blue", "gbc" } T.eq(shades("f0"), "0,3,3", v[1] .. "/" .. v[2] .. " $f0 maps to shades 0,3,3") T.eq(shades("e4"), "1,2,3", v[1] .. "/" .. v[2] .. " $e4 maps to the zone's shades 1,2,3") end +-- engine/battle/animations.asm:571-573 +for _, v in ipairs({ { "red", "og" }, { "yellow", "og" }, { "blue", "classic" }, { "red", "og_inv" } }) do + GameVersion.set(v[1]) + PaletteFX.setMode(v[2]) + T.eq(shades("f0"), "1,2,3", v[1] .. "/" .. v[2] .. " wAnimPalette is $e4 off the SGB") + T.eq(shades("f0x"), "2,1,3", v[1] .. "/" .. v[2] .. " the ball flicker xors $e4") +end GameVersion.set(prevVersion) PaletteFX.setMode(prevMode) diff --git a/tests/engine/champion_victory_music_bug2835.lua b/tests/engine/champion_victory_music_bug2835.lua index 94490b43..15ae7c90 100644 --- a/tests/engine/champion_victory_music_bug2835.lua +++ b/tests/engine/champion_victory_music_bug2835.lua @@ -40,6 +40,11 @@ local function finishWith(kind, result) restores = 0 local ok, err = pcall(b.finish, b) T.check(ok, kind .. "/" .. result .. ": finish runs" .. (ok and "" or (": " .. tostring(err)))) + for _ = 1, 400 do + local top = game.stack:top() + if not top or top == b or not top.update then break end + top:update(1 / 60) + end return restores end diff --git a/tests/engine/continue_info_layout_2684.lua b/tests/engine/continue_info_layout_2684.lua index 94509cb9..3a597865 100644 --- a/tests/engine/continue_info_layout_2684.lua +++ b/tests/engine/continue_info_layout_2684.lua @@ -67,7 +67,7 @@ eq(by, 11 * 8, "badge count on row 11") local dx, dy = at("151") eq(dx, 16 * 8, "dex count starts at hlcoord 16 (main_menu.asm:372)") eq(dy, 13 * 8, "dex count on row 13") -local tx, ty = at(" 47:27") +local tx, ty = at(" 4727") eq(tx, 13 * 8, "play time starts at hlcoord 13 (main_menu.asm:374)") eq(ty, 15 * 8, "play time on row 15") eq(bx + 2 * 8, tx + 6 * 8, "badge count ends flush with the play time") diff --git a/tests/engine/dex_entry_no_dexend_2923.lua b/tests/engine/dex_entry_no_dexend_2923.lua new file mode 100644 index 00000000..c0a74fa0 --- /dev/null +++ b/tests/engine/dex_entry_no_dexend_2923.lua @@ -0,0 +1,57 @@ +-- pokeyellow data/pokemon/dex_text.asm:1088 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +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, +} +package.loaded["src.render.HudTiles"] = { statusTile = function() end } + +local DexEntryMenu = require("src.ui.DexEntryMenu") + +local function hasText(text) + for _, c in ipairs(calls) do + if c.text == text then return true end + end + return false +end +local function anyContains(needle) + for _, c in ipairs(calls) do + if type(c.text) == "string" and c.text:find(needle, 1, true) then return true end + end + return false +end + +local game = { + data = { + pokemon = { + KOFFING = { + id = "KOFFING", name = "KOFFING", dex = 109, + dexEntry = { kind = "POISON GAS", heightFt = 2, heightIn = 0, + weight = 22, text = "_KoffingDexEntry" }, + }, + }, + text = { + _KoffingDexEntry = "In hot places, its\ninternal gases\ncould expand and\f" + .. "explode without\nany warning. Be\nvery careful!{DONE}", + }, + constants = { dexDigits = 3 }, + }, + save = { pokedex = { owned = { KOFFING = true } } }, +} + +local ns = DexEntryMenu.new(game, "KOFFING") +ns.picDelay = 0 +ns.page = 2 +ns:draw() +check(hasText("very careful!"), "last line is \"very careful!\"") +check(not anyContains("{DONE}"), "no {DONE} token reaches the page") +check(not hasText("very careful!."), "no full stop on a @@ entry") + +T.finish("dex entry without dexend 2923") diff --git a/tests/engine/ed_tile_import_1947.lua b/tests/engine/ed_tile_import_1947.lua index 1fce78df..b5706c9f 100644 --- a/tests/engine/ed_tile_import_1947.lua +++ b/tests/engine/ed_tile_import_1947.lua @@ -68,6 +68,7 @@ local BASE_SYMBOLS = { FontGraphics = { 4, 0 }, TextBoxGraphics = { 4, 4096 }, PokedexTileGraphics = { 4, 8192 }, + PTile = { 4, 12288 }, } local function fakeExtractor(withEd) @@ -105,7 +106,7 @@ do T.check(ok, "extractFont runs against the fake ROM: " .. tostring(data)) local edRead for _, read in ipairs(reads) do - if read.length == 8 then edRead = read end + if read.length == 8 and read.bank == 1 then edRead = read end end T.check(edRead ~= nil, "the Fonts stage reads an 8-byte tile") if edRead then diff --git a/tests/engine/falling_objects_delta_bug2428.lua b/tests/engine/falling_objects_delta_bug2428.lua index 003cd7bc..54e6b262 100644 --- a/tests/engine/falling_objects_delta_bug2428.lua +++ b/tests/engine/falling_objects_delta_bug2428.lua @@ -19,6 +19,9 @@ local data = { local p = AnimPlayer.new(data) local ok, err = pcall(p.start, p, "PETALS_TEST", true) T.check(ok, "petals compile past delta index 8: " .. tostring(err)) +-- engine/battle/animations.asm:2338 +T.eq(p.steps[1].dur, 10, "InitMultipleObjectsOAM reloads the tileset first") +table.remove(p.steps, 1) T.eq(#p.steps, 53, "52 three-frame ticks plus the ClearSprites frame") T.eq(#p.steps[1].sprites, 20, "twenty petals") T.eq(#p.steps[53].sprites, 0, "ClearSprites empties OAM") @@ -39,6 +42,8 @@ T.check(obj(9, 1).xf and obj(9, 1).x == 92, "index 9 wraps to 0 and flips left") T.eq(obj(10, 1).x, 91, "then walks the table leftward") p:start("LEAVES_TEST", true) +T.eq(p.steps[1].dur, 10, "leaves reload the tileset first too") +table.remove(p.steps, 1) T.eq(#p.steps, 53, "leaves run the same 52 ticks") T.eq(#p.steps[1].sprites, 3, "three leaves") diff --git a/tests/engine/mart_list_box_bug1887.lua b/tests/engine/mart_list_box_bug1887.lua index b92f8d33..cff1d924 100644 --- a/tests/engine/mart_list_box_bug1887.lua +++ b/tests/engine/mart_list_box_bug1887.lua @@ -128,8 +128,8 @@ do end) ~= nil, "its price is one tile row below the name") local price = found("draw", function(c) return c[2] == "¥600" end) if price then - eq(price[3] + FontStub.width("¥600"), 136, - "right-aligned through the BCD field (home/list_menu.asm:410-424)") + eq(price[3] + FontStub.width("¥600"), 144, + "right-aligned through the BCD field (home/list_menu.asm:421-424)") end check(found("draw", function(c) return c[2] == "CANCEL" end) ~= nil, "CANCEL is a real row (home/list_menu.asm:523-528)") diff --git a/tests/engine/menu_speed_bug2578_test.lua b/tests/engine/menu_speed_bug2578_test.lua index 6c33f8ca..8ee73584 100644 --- a/tests/engine/menu_speed_bug2578_test.lua +++ b/tests/engine/menu_speed_bug2578_test.lua @@ -99,6 +99,7 @@ T.eq(g:logicSpeed(), 4, "main PC cancel restores OVERWORLD SPEED") for i, id in ipairs({ "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }) do g.data.pokemon[id] = { id = id, name = id, dex = 132 + i } end +g.data.text._BillsHousePokemonListText2 = "Which POKéMON do\nyou want to see?{DONE}" OW:billsHousePokemonList() T.eq(g:logicSpeed(), 4, "Bill viewer opening text retains OVERWORLD SPEED") local bill = advanceToMenu() diff --git a/tests/engine/overworld_slot_seat_over_hidden_coin.lua b/tests/engine/overworld_slot_seat_over_hidden_coin.lua index 103ad9a1..5c8b739f 100644 --- a/tests/engine/overworld_slot_seat_over_hidden_coin.lua +++ b/tests/engine/overworld_slot_seat_over_hidden_coin.lua @@ -56,7 +56,7 @@ end T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject") local fakeSelf = setmetatable({ - map = { id = MAP_ID }, player = { facing = "up" }, + map = { id = MAP_ID }, player = { facing = "left" }, }, { __index = OW }) local function text(i) return (pushed[i] and pushed[i].text) or "" end diff --git a/tests/engine/pokedex_entry_layout_bug1341.lua b/tests/engine/pokedex_entry_layout_bug1341.lua index f45df507..8ce9b92e 100644 --- a/tests/engine/pokedex_entry_layout_bug1341.lua +++ b/tests/engine/pokedex_entry_layout_bug1341.lua @@ -68,12 +68,12 @@ ns.picDelay = 0 ns:draw() check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)") check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)") -check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)") +check(hasText("001", 32, 64), "dex number after the single-tile No and DOT, at (32,64)") check(hasText("HT", 72, 48), "HT label at (72,48)") check(hasText(" 2", 96, 48), "feet in cols 12-13") check(hasText("04", 120, 48), "inches in cols 15-16") check(hasText("WT", 72, 64), "WT label at (72,64)") -check(hasText(" 6.9", 88, 64), "weight right-aligned into cols 11-16") +check(hasText(" 69", 88, 64), "weight right-aligned into cols 11-16 with the DOT tile") check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)") check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104") check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page") diff --git a/tests/engine/pokedex_seen_placeholders_bug2163.lua b/tests/engine/pokedex_seen_placeholders_bug2163.lua index 6e41f163..8c0297ef 100644 --- a/tests/engine/pokedex_seen_placeholders_bug2163.lua +++ b/tests/engine/pokedex_seen_placeholders_bug2163.lua @@ -79,7 +79,7 @@ DexEntryMenu.render(game, def, nil, false, false, 1, {}) skeleton("owned") check(hasText(" 6", 96, 48), "owned: feet in cols 12-13") check(hasText("11", 120, 48), "owned: inches in cols 15-16") -check(hasText("1014.0", 88, 64), "owned: weight right-aligned into cols 11-16") +check(hasText("10140", 88, 64), "owned: weight right-aligned into cols 11-16") check(not hasText("?", 104, 48), "owned: no leftover placeholder digits") check(hasText("Very lazy. Just", 8, 88), "owned: the description prints") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index f0641a16..ea3ad152 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -242,24 +242,23 @@ do end end --- Boundary the fix must not have moved: a candy is refused mid-battle, so the --- field behavior above can never be mistaken for a battle regression. --- item_effects.asm:800-803, ItemUseVitamin reads wIsInBattle and --- `jp nz, ItemUseNotTime` before it ever falls into ItemUseMedicine, which is --- why the port's battle-side branch is a guard rather than a live path. A --- bare table stands in for the battle: nothing on this route reads it. +-- engine/items/item_effects.asm:800-803 do local game, mon = freshGame(3) - local list = useFromBag(game, { fakeBattle = true }, "RARE_CANDY") - if check(list ~= nil, "the battle bag reached the picker") then - eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing (ItemUseVitamin " - .. "-> ItemUseNotTime)") - eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed") + local list = BagMenu.new(game, { battle = { fakeBattle = true } }) + game.stack:push(list) + local row = rowFor(list, "RARE_CANDY") + if check(row ~= nil, "RARE CANDY row in the battle bag") then + list.index = row + list.onChoose(list.items[row], list) local box = game.stack:top() - if check(isBox(box), "the refusal prints") then + check(not isPicker(box), "the battle bag never opens the party picker") + if check(isBox(box), "the refusal prints from the bag") then check(box.text:find("time to use", 1, true) ~= nil, "with ItemUseNotTime's line: " .. tostring(box.text)) end + eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing") + eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed") end end diff --git a/tests/engine/route24_rocket_bug1550.lua b/tests/engine/route24_rocket_bug1550.lua index c90adbde..096da0c6 100644 --- a/tests/engine/route24_rocket_bug1550.lua +++ b/tests/engine/route24_rocket_bug1550.lua @@ -56,13 +56,19 @@ local ow = { } local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } } --- the prize has already been taken: the talk goes straight to the battle -game.save.flags.EVENT_GOT_NUGGET = true local doneCalls = 0 -handler(game, ow, npc, function() doneCalls = doneCalls + 1 end) +local function throughPitch() + pushed, engaged, doneCalls = {}, nil, 0 + game.save.flags.EVENT_GOT_NUGGET = nil + handler(game, ow, npc, function() doneCalls = doneCalls + 1 end) + for i = 1, 4 do + if pushed[i] and pushed[i].onDone then pushed[i].onDone() end + end +end -T.check(engaged ~= nil, "the recruiter engages") -T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)") +throughPitch() +T.check(engaged ~= nil, "the recruiter engages after the pitch") +T.eq(#pushed, 4, "no text box is pushed before the battle (#1550)") T.eq(engaged.skipBattleText, true, "skipBattleText stops the map text becoming the pre-battle box") T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText, @@ -71,19 +77,28 @@ T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText, -- the win: Route24AfterRocketBattleScript prints the top-leader line defeated = true engaged.onDone() -T.eq(#pushed, 1, "the win prints exactly one box") -T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText, +T.eq(#pushed, 5, "the win prints exactly one box") +T.eq(pushed[5].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText, "and it is .YouCouldBecomeATopLeaderText") -pushed[1].onDone() +pushed[5].onDone() T.eq(doneCalls, 1, "control returns once the box closes") -- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID -pushed, defeated, doneCalls = {}, false, 0 -handler(game, ow, npc, function() doneCalls = doneCalls + 1 end) +defeated = false +throughPitch() engaged.onDone() -T.eq(#pushed, 0, "a loss prints nothing") +T.eq(#pushed, 4, "a loss prints nothing") T.eq(doneCalls, 1, "and just unfreezes the player") +-- scripts/Route24.asm:138 +pushed, engaged, doneCalls = {}, nil, 0 +game.save.flags.EVENT_GOT_NUGGET = true +handler(game, ow, npc, function() doneCalls = doneCalls + 1 end) +T.eq(engaged, nil, "with the NUGGET taken he never battles again") +T.eq(pushed[1] and pushed[1].text, + Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText, + "only the top-leader line") + -- the prize arm: scripts/Route24.asm:149-158 local GameVersion = require("src.core.GameVersion") local savedVersion = GameVersion.current diff --git a/tests/engine/safari_gate_bug540.lua b/tests/engine/safari_gate_bug540.lua index ed53357d..8436a933 100644 --- a/tests/engine/safari_gate_bug540.lua +++ b/tests/engine/safari_gate_bug540.lua @@ -178,15 +178,15 @@ T.eq(leftCell.moves[1].tiles, 1, "one cell reaches (4,2)") leftCell.moves[1].onDone() T.check(leftCellGame.save.safari ~= nil, "the join prompt runs from (4,2)") --- talking to the worker from anywhere else has no warp above the player, so --- the auto-walk stays out of the way and he walks in himself +-- scripts/SafariZoneGate.asm:144-146 answer = true local far = newWorld(2, 4) local farGame = newGame(far) farGame.save.safari = nil gate.talk.TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER1(farGame, far, nil, nil) -T.check(farGame.save.safari ~= nil, "the talk path still starts the game") -T.eq(#far.moves, 0, "no auto-walk from a cell with no warp above it") +T.eq(farGame.save.safari, nil, "talking to the worker only welcomes you") +T.eq(#far.pushed, 1, "one box, no join prompt") +T.eq(#far.moves, 0, "no auto-walk from a talk") -- declining walks the player back off the trigger cell, unchanged answer = false diff --git a/tests/engine/slot_machine_boxes_bug1811.lua b/tests/engine/slot_machine_boxes_bug1811.lua index 4fbb7503..58ec8df7 100644 --- a/tests/engine/slot_machine_boxes_bug1811.lua +++ b/tests/engine/slot_machine_boxes_bug1811.lua @@ -73,6 +73,9 @@ eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bot 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 x2, x1 = drawn(bet, "×2"), drawn(bet, "×1") +eq(x2 and x2[4], 14 * 8, "×2 two rows down (engine/slots/slot_machine.asm:150-153)") +eq(x1 and x1[4], 16 * 8, "×1 two rows further") 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") @@ -88,13 +91,13 @@ 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") +eq(no and no[4], 15 * 8, "NO two rows below it (engine/slots/slot_machine.asm:136-138)") 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") +eq(cursor(onNo) and cursor(onNo)[4], 15 * 8, "and moves to the NO row") T.finish() diff --git a/tests/engine/slot_machine_lined_up_romtext.lua b/tests/engine/slot_machine_lined_up_romtext.lua index 699007c4..dc17bf19 100644 --- a/tests/engine/slot_machine_lined_up_romtext.lua +++ b/tests/engine/slot_machine_lined_up_romtext.lua @@ -16,14 +16,14 @@ local function mkSelf() return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine) end --- translated: the fake suffix reaches self.message, with the symbol --- concatenated in front and the payout substituted into the fake text +-- engine/slots/slot_machine.asm:617-631 do local self = mkSelf() Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!" self:resolveWin({ symbol = "CHERRY", payout = 8 }) - T.eq(self.message, "CHERRY FAKE-SUFFIX 8!", + T.eq(self.message, " FAKE-SUFFIX 8!", "a translated _LinedUpText reaches the lined-up message") + T.eq(self.winSymbol, "CHERRY", "the winning symbol is kept for its picture") Data.text._LinedUpText = nil end @@ -32,7 +32,7 @@ end do local self = mkSelf() self:resolveWin({ symbol = "CHERRY", payout = 8 }) - T.eq(self.message, "CHERRY lined up!\nScored 8 coins!", + T.eq(self.message, " lined up!\nScored 8 coins!", "no catalog entry still falls back to the English literal") end diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index d40f46f2..52dd43c5 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -303,13 +303,10 @@ T.eq(Timing.SHAKE_HORIZ_SLOW, 48, "type 3 slow horizontal, lb bc, 6, 2") T.eq(Timing.SHAKE_HORIZ_LIGHT, 18, "type 5 fast horizontal b=2") T.eq(Timing.SHAKE_HORIZ_SLOW2, 24, "type 6 slow horizontal, lb bc, 3, 2") --- Type 4 is the player's plain damaging move -- the most-seen animation in --- the game. AnimationBlinkMon is `ld c, 6` of hide/DelayFrames 5/show/ --- DelayFrames 5 (animations.asm:1360-1376): 60 frames, not the 20 the port --- used to run. -T.eq(Timing.BLINK_MON, 60, "AnimationBlinkMon is 6 x (5 hidden + 5 shown)") -T.eq(Timing.BLINK_MON % 10, 0, - "and divides into whole 10-frame blinks, matching fxHidden's period") +-- engine/battle/animations.asm:1360 +T.eq(Timing.BLINK_MON, 78, "AnimationBlinkMon is 6 x (5 hidden + 8 shown)") +T.eq(Timing.BLINK_MON % 13, 0, + "and divides into whole 13-frame blinks, matching fxHidden's period") -- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2 T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames") @@ -384,6 +381,13 @@ do T.eq(b.waitFrames, Timing.BLINK_MON, "the enemy blink still runs with animations off (#1384)") T.check(b.fx and b.fx.blink ~= nil, "and it targets the enemy pic") + local pattern = {} + for _ = 1, 13 do + pattern[#pattern + 1] = b:fxHidden(b.enemy) and "h" or "s" + b.fx.blink.frames = b.fx.blink.frames - 1 + end + T.eq(table.concat(pattern), "hhhhhssssssss", + "each blink is 5 frames hidden then 8 shown") end -- animations.asm:415-419 diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 15b1011a..355b8fac 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -261,7 +261,9 @@ check(overworld[1].tile == 0x14 and overworld[1].kind == "hshift", "water is an hshift entry on tile $14") check(table.concat(overworld[1].offsets, ",") == "1,2,3,2,1,0,7,0", "the water entry reproduces WATER_OFFSETS exactly") -check(overworld[1].period == 20, "water advances every 20 ticks") +check(overworld[1].period == 21 and overworld[1].phase == 1 + and overworld[2].period == 21, + "water+flower tilesets step every 21 ticks, water one tick first") check(overworld[2].tile == 0x03 and overworld[2].kind == "frames", "flower is a frames entry on tile $03") check(table.concat(overworld[2].sequence, ",") == "1,2,3,1,1,2,3,1", @@ -271,6 +273,7 @@ check(#overworld[2].images == 3, "the flower entry names its 3 frame images") local waterOnly = TileRenderer.defaultAnimatedTiles( { id = "PLATEAU", animation = "TILEANIM_WATER" }) check(#waterOnly == 1, "TILEANIM_WATER derives water alone") +check(waterOnly[1].period == 20, "water-only tilesets step every 20 ticks") local none = TileRenderer.defaultAnimatedTiles( { id = "HOUSE", animation = "TILEANIM_NONE" }) check(#none == 0, "a tileset with no animation derives nothing") diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua index fb89e9e4..60d36d87 100644 --- a/tests/mod_link_tests.lua +++ b/tests/mod_link_tests.lua @@ -498,7 +498,7 @@ sessionB:handle(wire(sessionA:confirm(true))) eq(sessionA.stage, "done", "the subset trade completes") local received = sessionA:apply(nil) eq(received.species, "PIDGEY", "the vanilla game received the agreed mon") -eq(partyVanilla[1], received, "and it landed in the slot that was given") +eq(partyVanilla[#partyVanilla], received, "and it was appended after the slot that was given") -- a full-verdict session sends the whole party and indexes it directly local fullSession = Protocol.TradeSession.new(Data, partyVanilla, { strict = true }) diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 9cf42d7a..310f0ff3 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -59,7 +59,7 @@ local function oldPaletteNameFor(def, lastOutdoorId) elseif ts == "CAVERN" then return "CAVE" elseif id == "LORELEIS_ROOM" then return "PALLET" elseif id == "BRUNOS_ROOM" then return "CAVE" - elseif TOWN_PALS[id] or id:match("^ROUTE_") then + elseif TOWN_PALS[id] or id:match("^ROUTE_%d+$") then return TOWN_PALS[id] or "ROUTE" end local last = lastOutdoorId or BOOT_HEAL_MAP @@ -72,7 +72,8 @@ do -- every map, with no outdoor memory and with each of the outdoor maps -- remembered, must land on the palette the literals used to pick -- "" stands for no outdoor memory at all - local lasts = { "", "PALLET_TOWN", "ROUTE_1", "INDIGO_PLATEAU", "CELADON_CITY" } + local lasts = { "", "PALLET_TOWN", "ROUTE_1", "INDIGO_PLATEAU", "CELADON_CITY", + "SAFFRON_CITY" } local mapIds = {} for id in pairs(Data.maps) do mapIds[#mapIds + 1] = id end table.sort(mapIds) @@ -90,6 +91,14 @@ do end end check(compared >= #mapIds * 2, "palette oracle covered every map") + for _, gate in ipairs({ "ROUTE_5_GATE", "ROUTE_6_GATE", "ROUTE_7_GATE", "ROUTE_8_GATE" }) do + local def = Data.maps[gate] + if def then + local state = setmetatable({ lastOutdoor = { id = "SAFFRON_CITY" } }, { __index = OW }) + check(state:paletteNameFor({ id = gate, def = def }) == "SAFFRON", + gate .. " entered from Saffron wears SAFFRON") + end + end end do diff --git a/tests/parity_D.lua b/tests/parity_D.lua index 1ebf24c7..d6917f5c 100644 --- a/tests/parity_D.lua +++ b/tests/parity_D.lua @@ -74,9 +74,11 @@ for _, mapId in ipairs(UNCOUNTED_MAPS) do eq(fakeGame.save.safari.steps, 10, mapId .. " leaves the safari step counter untouched") end --- Boundary: hitting 0 steps on a rest/secret house ends the game --- (SafariZoneCheckSteps's dec bc + zero-check, safari_game.asm:9-27). +-- safari_game.asm:14-24 fakeGame.save.safari = { balls = 30, steps = 1 } +local early = safariStepOn("SAFARI_ZONE_SECRET_HOUSE") +check(not early and fakeGame.save.safari.steps == 0, + "the step that reaches 0 is still allowed") local fired = safariStepOn("SAFARI_ZONE_SECRET_HOUSE") check(fired, "safariStep reports the game-over trigger at 0 steps") check(fakeGame.save.safari == nil, diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index 14256557..7dad3cf5 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -200,10 +200,8 @@ do eq(tb.fx.shakeProg, nil, "type 4 arms no shake (#354 must not regress it)") check(tb.fx.blink ~= nil and tb.fx.blink.target == tb.enemy, "type 4 blinks the enemy pic") - -- AnimationBlinkMon (animations.asm:1360-1376) is `ld c, 6` iterations - -- of hide + DelayFrames 5 + show + DelayFrames 5 = 60 frames; the port - -- once ran it in 20, a third of its length (Timing.BLINK_MON). - eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes") + -- engine/battle/animations.asm:1360 + eq(tb.waitFrames, 78, "for the 78 frames AnimationBlinkEnemyMon takes") end -- engine/battle/animations.asm PlayApplyingAttackAnimation diff --git a/tests/parity_bide_anim.lua b/tests/parity_bide_anim.lua index 5630090f..55ae50cb 100644 --- a/tests/parity_bide_anim.lua +++ b/tests/parity_bide_anim.lua @@ -207,7 +207,7 @@ do tb:continueBide(tb.player, tb.enemy) local rows = trace(tb) eq(tb.enemy.mon.hp, hpBefore, "nothing stored means no damage") - check(indexOfText(rows, "But, it failed!") ~= nil, "it prints the failure") + check(indexOfText(rows, "attack missed!") ~= nil, "it prints the miss") check(not anyAnim(rows, "BIDE"), "and plays no animation") end diff --git a/tests/parity_bike_gate_shove_bug518.lua b/tests/parity_bike_gate_shove_bug518.lua index 511c793d..f94bee75 100644 --- a/tests/parity_bike_gate_shove_bug518.lua +++ b/tests/parity_bike_gate_shove_bug518.lua @@ -86,14 +86,20 @@ do "first box is the guard's stop line") dismissTop() + -- scripts/Route16Gate1F.asm:16-80 + check(#ow.scriptMoves > 0 or ow.player.moving, + "closing the stop line queues the walk up to the counter") + check(Game.stack:top() == ow, "the explanation waits for the walk") + runFrames(ow, 200) + eq(ow.player.cellY, 7, "the player reached the counter before the explanation") local box2 = Game.stack:top() - check(box2 ~= nil, "the second (explanation) box opened") + check(box2 ~= ow, "the second (explanation) box opened at the counter") eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText, "second box is the guard's no-pedestrians explanation, verbatim") dismissTop() check(#ow.scriptMoves > 0 or ow.player.moving, - "closing the explanation queues the walk-up-then-shove movement") + "closing the explanation queues the shove") runFrames(ow, 200) check(not ow.player.moving, "the scripted movement has settled") eq(#ow.scriptMoves, 0, "no scripted move is left hanging") diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index 57870579..a1afd785 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -49,9 +49,12 @@ local stackStub = { end, } local textBoxStub = { - new = function(_, text, onDone) - local box = { kind = "text", text = text, onDone = onDone } + new = function(_, text, onDone, opts) + local box = { kind = "text", text = text, onDone = onDone, opts = opts } if onDone then onDone() end + if opts and type(opts.stay) == "table" and opts.stay.onShown then + opts.stay.onShown() + end return box end, } @@ -221,6 +224,12 @@ for _, p in ipairs(pushed) do if p.kind == "menu" then menu = p break end end check(menu ~= nil, "post-leave PC opens a menu") +local held +for _, p in ipairs(pushed) do + if p.kind == "text" and p.opts and p.opts.stay then held = p break end +end +-- engine/events/hidden_events/bills_house_pc.asm:94-95 +check(held ~= nil, "BillsHousePokemonListText2 is held under the list") local labels = {} for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL", diff --git a/tests/parity_box_mon_stats.lua b/tests/parity_box_mon_stats.lua index e8f64973..2eec884d 100644 --- a/tests/parity_box_mon_stats.lua +++ b/tests/parity_box_mon_stats.lua @@ -246,7 +246,7 @@ do }), "stubbed ListMenu on withdraw") -- the WITHDRAW row is what fires onAction; run it straight check(setUpvalue(withdraw, "monSubmenu", - function(_, _, _, onAction) onAction() end), + function(_, _, _, _, onAction) onAction() end), "stubbed monSubmenu on withdraw") check(setUpvalue(withdraw, "afterTransfer", function() end), "stubbed afterTransfer on withdraw") diff --git a/tests/parity_rival_loss_fossil_elevator.lua b/tests/parity_rival_loss_fossil_elevator.lua new file mode 100644 index 00000000..84dec2c2 --- /dev/null +++ b/tests/parity_rival_loss_fossil_elevator.lua @@ -0,0 +1,81 @@ +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.maps and Data.maps.ROUTE_22) then Data:load() end + +local S = require("tests.harness").suite("parity rival loss, fossil corner, hideout lift") +local check, eq = S.check, S.eq + +local story2 = require("data.scripts.story2") +local story3 = require("data.scripts.story3") +local story5 = require("data.scripts.story5") + +local function captureRows(mod, flags, x, y) + local rows + local ow = { + player = {}, + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + } + local game = { data = Data, save = { flags = flags } } + mod.onStep(game, ow, x, y) + return rows +end + +local function lossTarget(rows) + for i, row in ipairs(rows or {}) do + if row[1] == "rival_battle" then + local nxt = rows[i + 1] + return nxt and nxt[1] == "jump_if_false" and nxt[2] + end + end +end + +-- scripts/Route22.asm:152 +eq(lossTarget(captureRows(story5.ROUTE_22, { EVENT_GOT_POKEDEX = true }, 29, 4)), + "end", "Route 22 rival 1 stays shown after a lost battle") +eq(lossTarget(captureRows(story5.ROUTE_22, { EVENT_BEAT_GIOVANNI = true }, 29, 5)), + "end", "Route 22 rival 2 stays shown after a lost battle") +-- scripts/SSAnne2F.asm:127 +eq(lossTarget(captureRows(story5.SS_ANNE_2F, {}, 36, 8)), + "end", "S.S. Anne rival stays shown after a lost battle") +-- scripts/SilphCo7F.asm:209 +eq(lossTarget(captureRows(story5.SILPH_CO_7F, {}, 3, 2)), + "end", "Silph Co 7F rival stays shown after a lost battle") + +-- scripts/MtMoonB2F.asm:8 +do + local beaten = false + local nerd = {} + local ow = { + npcByIndex = function() return nerd end, + trainerDefeated = function() return beaten end, + } + local nb = story2.MT_MOON_B2F.noBattles + check(type(nb) == "function", "Mt. Moon B2F has a no-battles zone") + if nb then + check(not nb({}, ow, 12, 7), "battles stay on before the Super Nerd is beaten") + beaten = true + check(nb({}, ow, 12, 7), "the fossil corner blocks battles once he is beaten") + check(nb({}, ow, 11, 5) and nb({}, ow, 14, 8), "across the whole 4x4 area") + check(not nb({}, ow, 15, 7) and not nb({}, ow, 12, 9), "and nowhere outside it") + end +end + +-- scripts/RocketHideoutElevator.asm:51 +do + local game = { data = Data } + local want = { ROCKET_HIDEOUT_B1F = 5, ROCKET_HIDEOUT_B2F = 5, ROCKET_HIDEOUT_B4F = 3 } + for mapId, warpIdx in pairs(want) do + local ow = { map = { def = { warps = { {}, {} } } } } + story3.ROCKET_HIDEOUT_ELEVATOR.onEnter(game, ow, mapId) + eq(ow.map.def.warps[1].destWarp, warpIdx, + mapId .. ": the car lets out on the right-hand door tile") + eq(ow.map.def.warps[2].destMap, mapId, mapId .. ": both car warps lead there") + end +end + +S.finish() diff --git a/tests/parity_silph_rival_bug2241.lua b/tests/parity_silph_rival_bug2241.lua index 6f7ff02d..998ee212 100644 --- a/tests/parity_silph_rival_bug2241.lua +++ b/tests/parity_silph_rival_bug2241.lua @@ -58,10 +58,10 @@ for _, y in ipairs({ 2, 3 }) do local iJump, jumpRow = find(rows, "jump_if_false") check(iJump ~= nil and type(jumpRow[2]) == "string", ("(3,%d): the loss branch jumps to a label, not a row number"):format(y)) - local iLabel = find(rows, "label", jumpRow and jumpRow[2]) local iHide = find(rows, "hide_object") - check(iLabel and iHide and iHide == iLabel + 1, - ("(3,%d): the loss label sits right before hide_object"):format(y)) + -- scripts/SilphCo7F.asm:209 + eq(jumpRow and jumpRow[2], "end", + ("(3,%d): a lost battle halts with the rival still shown"):format(y)) local iWalk, walkRow = find(rows, "walk_npc") check(iWalk ~= nil and find(rows, "move_npc_to", 9) ~= nil, diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua index 5b4069f0..701323e4 100644 --- a/tests/parity_ss_anne_departure.lua +++ b/tests/parity_ss_anne_departure.lua @@ -130,19 +130,10 @@ do check(not (r[3] == 36 and r[4] == 4), ("x=%d rival must not retreat to the spawn (36,4)"):format(x)) end - -- jump_if_false on a lost battle has to clear the whole tail + -- scripts/SSAnne2F.asm:127 local jump = rowsOfKind(rows, "jump_if_false")[1] - local target = jump and jump[2] - if type(target) == "string" then - for i, r in ipairs(rows) do - if r[1] == "label" and r[2] == target then target = i break end - end - end - while type(target) == "number" and rows[target] - and rows[target][1] == "label" do - target = target + 1 - end - eq(target, #rows, "a lost battle jumps past the exit walk") + eq(jump and jump[2], "end", + "a lost battle halts before the exit walk and the hide") end end diff --git a/tests/parity_status_true_color.lua b/tests/parity_status_true_color.lua index bb7e6aea..846d68ae 100644 --- a/tests/parity_status_true_color.lua +++ b/tests/parity_status_true_color.lua @@ -71,13 +71,13 @@ check(screen.spriteTrueColor == true, local pw, ph = screen.sprite:getDimensions() local py = math.max(0, 56 - ph) --- LoadFlippedFrontSpriteByMonIndex: the draw is a negative x scale anchored --- at 8 + pw, so the covered rect still starts at x = 8 +-- home/pics.asm:68-76 +local px = 8 + math.max(0, 56 - math.floor((8 - pw / 8) / 2) * 8 - pw) local page1 = uiRects(function() screen:draw() end) eq(#page1, 1, "page 1 reports one true-color rect") if page1[1] then eq(("%d,%d,%d,%d"):format(page1[1].x, page1[1].y, page1[1].w, page1[1].h), - ("%d,%d,%d,%d"):format(8, py, pw, ph), + ("%d,%d,%d,%d"):format(px, py, pw, ph), "and it is the mirrored draw's rect, not the unflipped one") check(page1[1].colors == false, "and it is an unshaded zone, not a palette") diff --git a/tests/parity_warp_standing_flag.lua b/tests/parity_warp_standing_flag.lua index 421f5915..8d151161 100644 --- a/tests/parity_warp_standing_flag.lua +++ b/tests/parity_warp_standing_flag.lua @@ -139,4 +139,23 @@ do Input:reset() end +-- home/overworld.asm:254 +do + local ow = bootOn("SS_ANNE_2F_ROOMS", 23, 5, "right") + check(ow:canCollisionWarp(), "the cabin exit mat keeps the flag") + check(ow:checkEdgeExit("right") == false, + "RIGHT off the east edge on the SHIP tileset needs a carpet tile") + check(ow.taken == nil, "so the cabin mat does not warp sideways") +end + +-- home/overworld.asm:260 +do + local ow = bootOn("SEAFOAM_ISLANDS_B4F", 20, 17, "down") + ow.player.surfing = true + ow.standingOnWarp = true + check(ow:checkEdgeExit("down") == false, + "a surfing collision never checks warps") + check(ow.taken == nil, "so DOWN on the water stairs stays on B4F") +end + S.finish() diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index b557f926..ca02dee9 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -403,6 +403,8 @@ tB:handle(cA) eq(tA.stage, "done", "trade completes") local gotMon, evoTo = tA:apply(nil) eq(gotMon.species, "MACHOKE", "A received Machoke") +eq(partyA[1].species .. "," .. partyA[2].species, "PIDGEY,MACHOKE", + "the traded slot is removed and the received mon appended") eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)") -- the received mon keeps its SENDER's OT, not the receiver's (#215) eq(gotMon.ot, "RED", "A's received Machoke keeps sender B's OT name") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 44533824..8db622f3 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -236,6 +236,8 @@ eq(mv[1], "TACKLE", "Rattata L3 move 1") eq(mv[2], "TAIL_WHIP", "Rattata L3 move 2") local mv7 = Pokemon.movesAtLevel(Data.pokemon.RATTATA, 7) eq(mv7[3], "QUICK_ATTACK", "Rattata learns Quick Attack at 7") +eq(table.concat(Pokemon.movesAtLevel(Data.pokemon.VICTREEBEL, 29), ","), "RAZOR_LEAF,WRAP,POISONPOWDER,SLEEP_POWDER", "Victreebel L29 relearns pushed-out SLEEP_POWDER") +eq(table.concat(Pokemon.movesAtLevel(Data.pokemon.VILEPLUME, 29), ","), "PETAL_DANCE,POISONPOWDER,STUN_SPORE,SLEEP_POWDER", "Vileplume L29 relearns pushed-out moves") -- ---------------------------------------------------------------- screens & focus energy local reflDef = battler(fixedMon("RATTATA", 3), Data.pokemon.RATTATA) @@ -271,6 +273,16 @@ eq(result, "failed", "burn heal fails on healthy mon") hurt.hp = 0 ItemEffects.use(Data, save, "REVIVE", hurt) eq(hurt.hp, 9, "revive restores half HP") +do + local ppMon = fixedMon("BULBASAUR", 5) + ppMon.moves = { { id = "GROWL", pp = 40 } } + eq(ItemEffects.use(Data, save, "PP_UP", ppMon, nil, 1), "consumed", "PP UP on GROWL consumed") + eq(ppMon.moves[1].pp, 47, "PP UP adds at most 7 to a 40-PP move") + eq(ItemEffects.use(Data, save, "MAX_ETHER", ppMon, nil, 1), "consumed", "MAX ETHER on full PP-UPped move is used up") + ppMon.moves[1].ppUps = 0 + ppMon.moves[1].pp = 40 + eq(ItemEffects.use(Data, save, "MAX_ETHER", ppMon, nil, 1), "failed", "MAX ETHER on full plain move has no effect") +end local r2, payload = ItemEffects.use(Data, save, "TM_TOXIC", hurt) eq(r2, "learn", "TM06 teaches Toxic to Bulbasaur") eq(payload, "TOXIC", "TM payload is the move id") @@ -449,8 +461,8 @@ eq(misted.stages.attack, -1, "secondary stat drop pierces MIST") local misted2 = { stages = {}, mist = true, name = "MISTY", mon = {} } local mistMsgs = MoveEffects.primary.ATTACK_DOWN1_EFFECT(sideRng, sideUser, misted2) check(misted2.stages.attack == nil - and mistMsgs[1]:find("MIST", 1, true) ~= nil, - "primary stat drop still blocked by MIST") + and mistMsgs[1]:find("But, it failed!", 1, true) ~= nil, + "primary stat drop blocked by MIST prints But, it failed!") -- Substitute boundary: the move must fail when its quarter-HP cost would -- consume all current HP, preventing a zero-HP user with a live substitute. @@ -1763,6 +1775,12 @@ SaveData.save(Game.save) local loaded = SaveData.load() eq(loaded.inventory.POTION, 2, "save/load round trip") eq(loaded.party[1].species, "BULBASAUR", "party persisted") +do + local probe = { version = Game.save.version, player = { id = Game.save.player.id } } + check(not SaveData.previousFileDiffers(probe), "same trainer id: no older-file prompt") + probe.player.id = (Game.save.player.id or 0) + 1 + check(SaveData.previousFileDiffers(probe), "other trainer id: older-file prompt") +end -- ---------------------------------------------------------------- save/load deep round trip -- A representative save table survives SaveData.save -> load exactly diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index dabbaf7b..0cf5f546 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -1794,7 +1794,7 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): raw_1bpp("Version_GFX", 80, 8, "title/red_version.png") raw_2bpp( "PlayerCharacterTitleGraphics", 40, 56, "title/player.png", - matte=True) + transparent=True) raw_2bpp( "NintendoCopyrightLogoGraphics", 152, 8, "title/copyright.png") diff --git a/tools/extract/field.py b/tools/extract/field.py index 0f0224db..800328d8 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -1160,6 +1160,7 @@ def parse_credits(pokered): pointers = [] strings = {} label = None + open_label = None path = os.path.join(pokered, "data/credits/credits_text.asm") for lineno, line in read_asm(path): s = line.strip() @@ -1171,14 +1172,26 @@ def parse_credits(pokered): if m and m.group(1) != "CreditsTextPointers": label = m.group(1) continue - # Optional trailing @ terminator (Yellow omits it on some lines). m = re.match(r'db\s+(-\d+),\s*"([^"]*)"$', s) - if m and label: - strings[label] = { - "column": 9 + int(m.group(1)), # hlcoord 9,6 + signed offset + if m and label and label not in strings: + strings[label] = [{ + "column": 9 + int(m.group(1)), "text": text.decode_string(m.group(2), lineno, "data/credits/credits_text.asm"), - } + }] + open_label = None if m.group(2).endswith("@") else label + continue + m = re.match(r'next\s+"([^"]*)"$', s) + if m and label and label == open_label: + first = strings[label][0] + strings[label].append({ + "column": first["column"], + "text": text.decode_string(m.group(1), lineno, + "data/credits/credits_text.asm"), + }) + open_label = None if m.group(1).endswith("@") else label + continue + open_label = None if len(pointers) != len(cred_names) or any(n is None for n in cred_names): util.die("credits: text pointer table does not match CRED_* constants") @@ -1221,7 +1234,7 @@ def parse_credits(pokered): label = pointers[cred_index[tok]] if label not in strings: util.die(f"credits_order.asm:{lineno}: no string for {tok}") - current["lines"].append(dict(strings[label])) + current["lines"].extend(dict(line) for line in strings[label]) else: util.die(f"credits_order.asm:{lineno}: unknown token {tok}") if not the_end_seen or current["lines"]: diff --git a/tools/extract/text.py b/tools/extract/text.py index 165ab0c7..803b5da6 100644 --- a/tools/extract/text.py +++ b/tools/extract/text.py @@ -27,7 +27,7 @@ from .util import read_asm, warn # Multi-char charmap sequences that expand to plain text. EXPANSIONS = { "#": "POKé", - "": "POKéMON", + "": "", "": "PC", "": "TM", "": "TRAINER", diff --git a/tools/make_blue_manifest.py b/tools/make_blue_manifest.py index cc85d760..dad5b56d 100644 --- a/tools/make_blue_manifest.py +++ b/tools/make_blue_manifest.py @@ -78,7 +78,10 @@ def derive(red, pokered, symbols_path): util.ASM_DEFINES = {"_BLUE"} try: blue["field"]["presetNames"] = field.parse_preset_names(pokered) + the_end = blue["field"]["credits"].get("theEnd") blue["field"]["credits"] = field.parse_credits(pokered) + if the_end: + blue["field"]["credits"]["theEnd"] = the_end finally: util.ASM_DEFINES = saved diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py index af0ef8dc..c2811619 100755 --- a/tools/make_rom_manifest.py +++ b/tools/make_rom_manifest.py @@ -554,6 +554,7 @@ DIRECT_SYMBOLS = { "GymLeaderFaceAndBadgeTileGraphics", "ItemNames", "ItemPrices", + "ItemUseMedicine.emptyPartyText", "KeyItemFlags", "MewBaseStats", "MonPartyData", @@ -568,6 +569,7 @@ DIRECT_SYMBOLS = { "Moves", "NothingWildMons", "OldManPicBack", + "PTile", "PlantIconFrame1", "PlantIconFrame2", "PokeballTileGraphics", diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 4542e02b..2fa2a0ed 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -116,6 +116,8 @@ YELLOW_EXTRA_SYMBOLS = ( "PikachuMoodLookupTable", "PikaPicAnimationScriptPointerLookupTable", "MapSpecificPikachuExpression.Emotions", "IsPlayerPikachuAsleepInParty", "PikaPicAnimThunderboltPals", + # engine/battle/read_trainer_party.asm:87 + "SpecialTrainerMoves", # Yellow-only overworld player surf sprite, loaded outside # SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) -- # needs its own extract like RedBikeSprite. (RFC 0001) @@ -445,12 +447,22 @@ def derive(red, pokeyellow, symbols_path): try: yellow["field"]["presetNames"] = field.parse_preset_names(pokeyellow) try: + the_end = yellow["field"]["credits"].get("theEnd") yellow["field"]["credits"] = field.parse_credits(pokeyellow) + if the_end: + yellow["field"]["credits"]["theEnd"] = the_end except SystemExit as exc: print(f"warning: parse_credits failed ({exc}); keeping Red credits") # TODO: hand-author a Yellow credits banner if pret layout drifts. yellow["field"]["trades"] = field.parse_trades(pokeyellow) yellow["field"]["superRod"] = field.parse_super_rod_yellow(pokeyellow) + hidden_items, _, _, hidden = field.parse_hidden_events(pokeyellow) + yellow["field"]["hiddenItems"] = hidden_items + extras = yellow["field"]["hiddenExtras"] + extras["pcTiles"] = hidden["pcTiles"] + extras["benchGuys"] = { + map_id: guys for map_id, guys in extras["benchGuys"].items() + if map_id in hidden["benchGuys"]} finally: util.ASM_DEFINES = saved diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index e8ac6f53..5b409d6e 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -9831,22 +9831,22 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, @@ -20670,6 +20670,10 @@ 1, 17928 ], + "ItemUseMedicine.emptyPartyText": [ + 3, + 23273 + ], "IvysaurPicBack": [ 9, 20823 @@ -21170,6 +21174,10 @@ 10, 16740 ], + "PTile": [ + 4, + 27356 + ], "PalletTown_h": [ 6, 17057 diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 221611c3..d18f2e71 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -5169,7 +5169,30 @@ "lines": [], "mon": "PARASECT" } - ] + ], + "theEnd": { + "display": "T H E E N D", + "height": 16, + "letterHeight": 16, + "letterWidth": 8, + "letters": "THEND", + "path": "assets/generated/credits/the_end.png", + "pattern": [ + 0, + -1, + 1, + -1, + 2, + -1, + -1, + 2, + -1, + 3, + -1, + 4 + ], + "width": 40 + } }, "cutTreeSwaps": [ { @@ -9808,22 +9831,22 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POK\u00e9MON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, @@ -20647,6 +20670,10 @@ 1, 17928 ], + "ItemUseMedicine.emptyPartyText": [ + 3, + 23273 + ], "IvysaurPicBack": [ 9, 20823 @@ -21147,6 +21174,10 @@ 10, 16740 ], + "PTile": [ + 4, + 27356 + ], "PalletTown_h": [ 6, 17057 diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 1bf224c3..cfdf77e9 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -4670,6 +4670,10 @@ { "column": 3, "text": "YELLOW VERSION" + }, + { + "column": 3, + "text": " STAFF" } ], "mon": "VENUSAUR" @@ -4924,6 +4928,10 @@ { "column": 5, "text": "SHOGAKUKAN" + }, + { + "column": 5, + "text": "PRODUCTION" } ], "mon": "OMASTAR" @@ -4932,8 +4940,8 @@ "fade": true, "lines": [ { - "column": 6, - "text": "××××××××" + "column": 3, + "text": "PIKACHU VOICE" }, { "column": 4, @@ -5156,7 +5164,30 @@ "lines": [], "mon": "GENGAR" } - ] + ], + "theEnd": { + "display": "T H E E N D", + "height": 16, + "letterHeight": 16, + "letterWidth": 8, + "letters": "THEND", + "path": "assets/generated/credits/the_end.png", + "pattern": [ + 0, + -1, + 1, + -1, + 2, + -1, + -1, + 2, + -1, + 3, + -1, + 4 + ], + "width": 40 + } }, "cutTreeSwaps": [ { @@ -5618,27 +5649,6 @@ "y": 4 } ], - "SAFARI_ZONE_EAST_REST_HOUSE": [ - { - "facing": "left", - "x": 0, - "y": 4 - } - ], - "SAFARI_ZONE_NORTH_REST_HOUSE": [ - { - "facing": "left", - "x": 0, - "y": 4 - } - ], - "SAFARI_ZONE_WEST_REST_HOUSE": [ - { - "facing": "left", - "x": 0, - "y": 4 - } - ], "SAFFRON_POKECENTER": [ { "facing": "up", @@ -5756,13 +5766,6 @@ ] }, "pcTiles": { - "CELADON_HOTEL": [ - { - "facing": "up", - "x": 13, - "y": 3 - } - ], "CELADON_MANSION_2F": [ { "facing": "up", @@ -5852,27 +5855,6 @@ "y": 3 } ], - "SAFARI_ZONE_EAST_REST_HOUSE": [ - { - "facing": "up", - "x": 13, - "y": 3 - } - ], - "SAFARI_ZONE_NORTH_REST_HOUSE": [ - { - "facing": "up", - "x": 13, - "y": 3 - } - ], - "SAFARI_ZONE_WEST_REST_HOUSE": [ - { - "facing": "up", - "x": 13, - "y": 3 - } - ], "SAFFRON_POKECENTER": [ { "facing": "up", @@ -6105,16 +6087,23 @@ ], "CERULEAN_CAVE_1F": [ { - "item": "RARE_CANDY", - "x": 14, - "y": 11 + "item": "PP_UP", + "x": 18, + "y": 7 + } + ], + "CERULEAN_CAVE_2F": [ + { + "item": "PP_UP", + "x": 16, + "y": 13 } ], "CERULEAN_CAVE_B1F": [ { - "item": "ULTRA_BALL", - "x": 27, - "y": 3 + "item": "PP_UP", + "x": 8, + "y": 14 } ], "CERULEAN_CITY": [ @@ -9943,22 +9932,22 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POKéMON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POKéMON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POKéMON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POKéMON MANSION", + "name": " MANSION", "x": 2, "y": 15 }, @@ -23013,6 +23002,10 @@ 10, 16778 ], + "PTile": [ + 4, + 22146 + ], "PalletTownFisherText": [ 6, 20497 @@ -26253,6 +26246,10 @@ 9, 18497 ], + "SpecialTrainerMoves": [ + 14, + 23659 + ], "SpinnerArrowAnimTiles": [ 17, 20775