commit a5d2e77e7d8490a685e8c11f3b81b15368c729a3 Author: bryanthaboi Date: Fri Jul 17 20:30:02 2026 -0400 initial commit diff --git a/README.md b/README.md new file mode 100644 index 00000000..1c78492f --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Pokemon Gen 1 Recompilation Project + +A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are +hand-written Lua; game data and graphics are decoded from a ROM supplied by +the player. + +This project does not include a ROM, emulate the Game Boy, transpile assembly, +or download a disassembly. A canonical US Pokemon Red ROM is the only game +content input. + +```text +first boot +Pokemon Red ROM -> in-app Lua importer -> private LÖVE save directory + -> generated Lua data and PNGs + -> compact audio channel programs + -> LÖVE2D engine +``` + +The ROM is verified, used during import, and then released from memory. It is +not copied into the cache. Later launches load the private generated cache and +do not ask for the ROM again. + +## Packaged App + +Open the desktop app. On first boot, choose your legally obtained `.gb` file +or drop it onto the window. Import takes a few seconds and the game starts +automatically. + +Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1 +`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data. +The packaged app contains neither a ROM nor pre-extracted game data. + +Music, sound effects, and cries are synthesized while the game runs from +compact Game Boy audio channel programs copied out of the verified ROM. No +WAV or OGG library is bundled or generated. + +## Source Checkout + +The source launchers retain an optional Python workflow for developers. Place +the ROM in the project folder and double-click `Play-Mac.command` or +`Play-Windows.bat`, or run: + +```sh +scripts/setup.sh --rom "/path/to/Pokemon Red.gb" +scripts/run.sh +``` + +Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb +powershell -ExecutionPolicy Bypass -File scripts\run.ps1 +``` + +The setup scripts also accept `ROM_PATH`. With no argument, they use the first +`.gb` file in the project root. + +## Developer Data Build + +Requirements: Python 3.10+ and Pillow. + +```sh +python3 -m pip install pillow +python3 tools/build_data.py --rom "/path/to/Pokemon Red.gb" --clean +``` + +This command produces the data modules and 495 PNGs in the source tree for +development and parity checks. It is not used by the packaged app. + +## Running + +Requires LÖVE 11.x: + +```sh +love . +``` + +Controls: arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is +B; Escape opens START. F1 saves and F2 loads. Controllers are supported. + +## Link Play + +START > LINK connects two copies directly over UDP. The host chooses HOST A +GAME and shares the shown address; the other player chooses JOIN A GAME. +The default port is 7777 and can be overridden with `POKEPORT_LINK_PORT`. + +## Save Editor + +Edit party, boxes, items, events, map location, and Pokédex flags without +playing through the game. Close the game first, then from the repo root: + +```sh +love . --editor +# or +POKEPORT_EDITOR=1 love . +# open a specific save +love . --editor --save "/path/to/save.lua" +``` + +By default it loads the game's LÖVE save (`save.lua`): + +- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua (or without the LOVE in a built version)` +- Linux: `~/.local/share/love/pokemon-love2d/save.lua` +- Windows: `%APPDATA%\love\pokemon-love2d\save.lua` + +If that file is missing or you want another copy, use **Open...**, drop a +`save.lua` onto the window, or pass `--save`. Each write makes a +`save.lua.bak-YYYYMMDD-HHMMSS` backup first. + +See `tools/save-editor/README.md` for headless tests. + +## Layout + +```text +tools/ ROM decoder, save editor, and developer verification tools +data/generated/ generated Lua game data (gitignored) +data/scripts/ hand-ported map behavior +assets/generated generated graphics and compact audio cache (gitignored) +src/ hand-written LÖVE engine +scripts/ setup, run, and packaging helpers +mobile/ Android and iOS build trees +tests/ headless behavior and parity suites +docs/ architecture, behavior notes, and platform docs +``` + +See `docs/architecture.md` for runtime details and +`docs/behavior-porting-notes.md` for formula provenance. + +## Delete generated files (mac) + +```sh +rm -rf data/generated assets/generated \ + "$HOME/Library/Application Support/LOVE/pokemon-love2d/data/generated" \ + "$HOME/Library/Application Support/LOVE/pokemon-love2d/assets/generated" + +rm -f "$HOME/Library/Application Support/LOVE/pokemon-love2d/rom-cache.complete" + +``` + diff --git a/assets/logo/bcg.png b/assets/logo/bcg.png new file mode 100644 index 00000000..af02cba7 Binary files /dev/null and b/assets/logo/bcg.png differ diff --git a/assets/logo/bcg.webp b/assets/logo/bcg.webp new file mode 100644 index 00000000..d1ec6be8 Binary files /dev/null and b/assets/logo/bcg.webp differ diff --git a/assets/logo/logo.png b/assets/logo/logo.png new file mode 100755 index 00000000..0d5be97b Binary files /dev/null and b/assets/logo/logo.png differ diff --git a/assets/logo/pokemon_logo.png b/assets/logo/pokemon_logo.png new file mode 100644 index 00000000..c81b88e1 Binary files /dev/null and b/assets/logo/pokemon_logo.png differ diff --git a/conf.lua b/conf.lua new file mode 100644 index 00000000..11485eab --- /dev/null +++ b/conf.lua @@ -0,0 +1,44 @@ +function love.conf(t) + local editor = os.getenv("POKEPORT_EDITOR") == "1" + if arg then + for _, a in ipairs(arg) do + if a == "--editor" then editor = true end + end + end + -- main.lua runs in the same Lua state right after conf.lua; stash the + -- decision in a global so it doesn't need to reparse `arg`. + _G.POKEPORT_EDITOR_MODE = editor + + if editor then + t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor" + t.window.title = "Pokemon Save Editor" + t.window.width = 1280 + t.window.height = 800 + else + t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d" + t.window.title = "Pokemon Red (Gen 1 Recompilation Project)" + t.window.width = 160 * 4 + t.window.height = 144 * 4 + end + t.version = "11.5" + t.window.vsync = 1 + t.modules.joystick = true + t.modules.physics = false + + -- love.system is not loaded during love.conf; love._os is set by the + -- engine before conf runs (LÖVE 11.x / 11.5). + local osName = love._os + local mobile = osName == "Android" or osName == "iOS" + if mobile then + -- On Android/iOS, width/height aspect picks portrait vs landscape + -- (fullscreen alone is not enough). Use a tall portrait size; the + -- OS then resizes to the real display. highdpi is required for + -- Retina iOS (Android always behaves as highdpi). + t.window.width = 1080 + t.window.height = 1920 + t.window.fullscreen = true + t.window.highdpi = true + else + t.window.resizable = true + end +end diff --git a/data/scripts/ai_classes.lua b/data/scripts/ai_classes.lua new file mode 100644 index 00000000..59d84470 --- /dev/null +++ b/data/scripts/ai_classes.lua @@ -0,0 +1,33 @@ +-- Per-trainer-class battle AI: item use and switching. +-- Hand-ported from data/trainers/ai_pointers.asm (uses per Pokémon + +-- routine) and engine/battle/trainer_ai.asm (the routines). Classes +-- not listed use GenericAI (never uses items or switches). +-- +-- chance is out of 256 (the routine's `cp X percent` threshold on a +-- random byte). hpBelow means "only when HP < max/N". onStatus means +-- "always, but only when the active mon has a status condition". + +return { + 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" }, + OPP_COOLTRAINER_M = { uses = 2, chance = 64, item = "X_ATTACK" }, + -- CooltrainerF's 25% roll is dead code in the original (the ret nc is + -- commented out); she heals below 1/10 and switches below 1/5 + OPP_COOLTRAINER_F = { uses = 1, item = "HYPER_POTION", hpBelow = 10, + switchBelow = 5 }, + OPP_BRUNO = { uses = 2, chance = 64, item = "X_DEFEND" }, + OPP_BROCK = { uses = 5, onStatus = true, item = "FULL_HEAL" }, + OPP_MISTY = { uses = 1, chance = 64, item = "X_DEFEND" }, + OPP_LT_SURGE = { uses = 1, chance = 64, item = "X_SPEED" }, + OPP_ERIKA = { uses = 1, chance = 128, item = "SUPER_POTION", hpBelow = 10 }, + OPP_KOGA = { uses = 2, chance = 64, item = "X_ATTACK" }, + OPP_BLAINE = { uses = 2, chance = 64, item = "SUPER_POTION" }, + OPP_SABRINA = { uses = 1, chance = 64, item = "HYPER_POTION", hpBelow = 10 }, + OPP_RIVAL2 = { uses = 1, chance = 32, item = "POTION", hpBelow = 5 }, + OPP_RIVAL3 = { uses = 1, chance = 32, item = "FULL_RESTORE", hpBelow = 5 }, + OPP_LORELEI = { uses = 2, chance = 128, item = "SUPER_POTION", hpBelow = 5 }, + OPP_AGATHA = { uses = 2, switchChance = 20, chance = 128, + item = "SUPER_POTION", hpBelow = 4 }, + OPP_LANCE = { uses = 1, chance = 128, item = "HYPER_POTION", hpBelow = 5 }, +} diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua new file mode 100644 index 00000000..ea9cfb97 --- /dev/null +++ b/data/scripts/celadon_eevee.lua @@ -0,0 +1,34 @@ +-- Hand-ported from pret/pokered scripts/CeladonMansionRoofHouse.asm +-- (CeladonMansionRoofHouseEeveePokeballText): the poke ball on the table +-- holds an Eevee (level 25). The text_asm calls GivePokemon immediately +-- (no confirm prompt) and, on success, hides the ball object +-- (TOGGLE_CELADON_MANSION_EEVEE_GIFT predef HideObject, persisted in +-- wToggleableObjectFlags) -- the hidden ball is the original's whole +-- re-gift guard. If the party AND box are full GivePokemon fails +-- (BoxIsFullText, .party_full) and the ball stays for later. +-- +-- EVENT_GOT_EEVEE is port-internal bookkeeping with no pokered +-- equivalent, kept for save compatibility: rows 1-4 also self-heal +-- older saves (flag set before the port hid the ball) by hiding the +-- leftover ball on the next interaction. + +return { + talk = { + TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = { + { "check_flag", "EVENT_GOT_EEVEE" }, -- 1 + { "jump_if_false", 5 }, -- 2 + { "hide_object", "CELADON_MANSION_ROOF_HOUSE", + "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves) + { "jump", 13 }, -- 4 + { "give_pokemon", "EEVEE", 25 }, -- 5 + { "jump_if_false", 12 }, -- 6 (party+box full) + { "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle) + { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8 + { "set_flag", "EVENT_GOT_EEVEE" }, -- 9 + { "hide_object", "CELADON_MANSION_ROOF_HOUSE", + "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10 + { "jump", 13 }, -- 11 + { "show_text", "_BoxIsFullText" }, -- 12 + }, + }, +} diff --git a/data/scripts/flavor/bike_shop.lua b/data/scripts/flavor/bike_shop.lua new file mode 100644 index 00000000..e0273216 --- /dev/null +++ b/data/scripts/flavor/bike_shop.lua @@ -0,0 +1,33 @@ +-- BikeShop (BIKE_SHOP) flavor dialogue +-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm +-- +-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle +-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent +-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the +-- scope of these two flavor NPCs and is left unported here. + +return { + BIKE_SHOP = { + talk = { + -- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm): + -- always shows the same flavor line, no branching. + TEXT_BIKESHOP_MIDDLE_AGED_WOMAN = { + { "face_player" }, + { "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 + TEXT_BIKESHOP_YOUNGSTER = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_BICYCLE" }, -- 2 + { "jump_if_true", 5 }, -- 3 + { "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4 + { "jump", 6 }, -- 5 + { "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6 + }, + }, + }, +} diff --git a/data/scripts/flavor/celadon_city.lua b/data/scripts/flavor/celadon_city.lua new file mode 100644 index 00000000..5d0cb328 --- /dev/null +++ b/data/scripts/flavor/celadon_city.lua @@ -0,0 +1,15 @@ +-- CeladonCity flavor talk scripts (pokered/scripts/CeladonCity.asm) +return { + CELADON_CITY = { + talk = { + -- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far + -- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends. + -- The cry playback has no port-side equivalent command, so we just + -- show the flavor line. + TEXT_CELADONCITY_POLIWRATH = { + {"face_player"}, + {"show_text", "_CeladonCityPoliwrathText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/celadon_mansion_1f.lua b/data/scripts/flavor/celadon_mansion_1f.lua new file mode 100644 index 00000000..9e8c3f3b --- /dev/null +++ b/data/scripts/flavor/celadon_mansion_1f.lua @@ -0,0 +1,33 @@ +-- CeladonMansion1F flavor talk scripts (pokered/scripts/CeladonMansion1F.asm) +return { + CELADON_MANSION_1F = { + talk = { + -- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far + -- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends. + -- The cry playback has no port-side equivalent command, so we just + -- show the flavor line. + TEXT_CELADONMANSION1F_CLEFAIRY = { + {"face_player"}, + {"show_text", "_CeladonMansion1FClefairyText"}, + }, + + -- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far + -- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends. + -- The cry playback has no port-side equivalent command, so we just + -- show the flavor line. + TEXT_CELADONMANSION1F_MEOWTH = { + {"face_player"}, + {"show_text", "_CeladonMansion1FMeowthText"}, + }, + + -- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far + -- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends. + -- The cry playback has no port-side equivalent command, so we just + -- show the flavor line. + TEXT_CELADONMANSION1F_NIDORANF = { + {"face_player"}, + {"show_text", "_CeladonMansion1FNidoranFText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/celadon_mansion_3f.lua b/data/scripts/flavor/celadon_mansion_3f.lua new file mode 100644 index 00000000..e13d5a3e --- /dev/null +++ b/data/scripts/flavor/celadon_mansion_3f.lua @@ -0,0 +1,34 @@ +-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm +-- CeladonMansion3FGameDesignerText): text_asm counts set bits in +-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew). +-- If the player owns >= 150 species, shows the "completed" text +-- (originally followed by DisplayDiploma, which has no equivalent UI +-- in this port, so we just show the congratulatory text); otherwise +-- shows the normal encouragement text. +return { + CELADON_MANSION_3F = { + talk = { + TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done) + local t = game.data.text + local dex = game.save.pokedex + local owned = 0 + if dex and dex.owned then + for _ in pairs(dex.owned) do + owned = owned + 1 + end + end + -- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered) + local label, fallback + if owned >= 150 then + label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText", + "Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!" + else + label, fallback = "_CeladonMansion3FGameDesignerText", + "Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!" + end + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, t[label] or fallback, done)) + end, + }, + }, +} diff --git a/data/scripts/flavor/cerulean_badge_house.lua b/data/scripts/flavor/cerulean_badge_house.lua new file mode 100644 index 00000000..106fc0c8 --- /dev/null +++ b/data/scripts/flavor/cerulean_badge_house.lua @@ -0,0 +1,65 @@ +-- CeruleanBadgeHouse (pokered/scripts/CeruleanBadgeHouse.asm) +-- +-- CeruleanBadgeHouseMiddleAgedManText is a text_asm: it prints a +-- greeting, then loops a badge-description menu (LoadItemList / +-- DisplayListMenuID over CeruleanBadgeHouseBadgeTextPointers) until the +-- player backs out with B, then prints a goodbye line. The badge list +-- is the fixed set of all 8 badges (not filtered by what the player +-- owns) -- it's an explanatory menu, not a real item pick. + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +-- CeruleanBadgeHouseBadgeTextPointers / .BadgeItemList +local BADGE_ORDER = { + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", +} +local BADGE_TEXT = { + BOULDERBADGE = "_CeruleanBadgeHouseBoulderBadgeText", + CASCADEBADGE = "_CeruleanBadgeHouseCascadeBadgeText", + THUNDERBADGE = "_CeruleanBadgeHouseThunderBadgeText", + RAINBOWBADGE = "_CeruleanBadgeHouseRainbowBadgeText", + SOULBADGE = "_CeruleanBadgeHouseSoulBadgeText", + MARSHBADGE = "_CeruleanBadgeHouseMarshBadgeText", + VOLCANOBADGE = "_CeruleanBadgeHouseVolcanoBadgeText", + EARTHBADGE = "_CeruleanBadgeHouseEarthBadgeText", +} + +local function middleAgedMan(game, ow, npc, done) + local t = game.data.text + + local function loop() + -- .loop: print WhichBadgeText, then show the badge list menu again + push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function() + local ListMenu = require("src.ui.ListMenu") + local items = {} + for _, id in ipairs(BADGE_ORDER) do + local idef = game.data.items[id] + items[#items + 1] = { label = idef and idef.name or id, value = id } + end + local menu = ListMenu.new(game, "", items, { + onChoose = function(item) + push(game, t[BADGE_TEXT[item.value]], loop) + end, + onCancel = function() + -- .done: VisitAnyTimeText, then TextScriptEnd + push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done) + end, + }) + game.stack:push(menu) + end) + end + + push(game, t._CeruleanBadgeHouseMiddleAgedManText, loop) +end + +return { + CERULEAN_BADGE_HOUSE = { + talk = { + TEXT_CERULEANBADGEHOUSE_MIDDLE_AGED_MAN = middleAgedMan, + }, + }, +} diff --git a/data/scripts/flavor/cerulean_cave_b1f.lua b/data/scripts/flavor/cerulean_cave_b1f.lua new file mode 100644 index 00000000..43419216 --- /dev/null +++ b/data/scripts/flavor/cerulean_cave_b1f.lua @@ -0,0 +1,27 @@ +-- Mewtwo (scripts/CeruleanCaveB1F.asm CeruleanCaveB1FMewtwoText + +-- home/trainers.asm TalkToTrainer; MEWTWO 70 from +-- data/maps/objects/CeruleanCaveB1F.asm). +-- +-- The text_asm loads MewtwoTrainerHeader and calls TalkToTrainer: +-- MewtwoBattleText is text_far "Mew!" + text_asm PlayCry MEWTWO + +-- WaitForSoundToFinish, then the wild battle starts -- or, when +-- EVENT_BEAT_MEWTWO is already set, the (identical) after-battle text +-- prints and nothing else happens. EndTrainerBattle sets +-- EVENT_BEAT_MEWTWO and hides the object on any non-blackout result +-- (win, catch or flee) -- static_battle mirrors that. + +local M = {} + +M.CERULEAN_CAVE_B1F = { + talk = { + TEXT_CERULEANCAVEB1F_MEWTWO = { + { "play_cry", "MEWTWO" }, -- 1 text_asm PlayCry + { "show_text", "_MewtwoBattleText" }, -- 2 "Mew!" + { "check_flag", "EVENT_BEAT_MEWTWO" }, -- 3 + { "jump_if_true", 6 }, -- 4 already beaten: text only + { "static_battle", "MEWTWO", 70, "EVENT_BEAT_MEWTWO" }, -- 5 + }, + }, +} + +return M diff --git a/data/scripts/flavor/cerulean_city.lua b/data/scripts/flavor/cerulean_city.lua new file mode 100644 index 00000000..93d0ba9b --- /dev/null +++ b/data/scripts/flavor/cerulean_city.lua @@ -0,0 +1,55 @@ +-- Cerulean City flavor dialogue (pokered/scripts/CeruleanCity.asm) +-- +-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several +-- flavor lines (no flags, no branching outcome) -- ported as a weighted +-- math.random pick each time the NPC is talked to. + +local M = {} + +local function push(game, ow, npc, done, text) + local TextBox = require("src.render.TextBox") + npc:facePlayer(ow.player) + game.stack:push(TextBox.new(game, text, done)) +end + +M.CERULEAN_CITY = { + talk = { + -- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393) + -- cp 180 -> 76/256 chance of 1st; cp 100 -> 80/256 chance of 2nd; + -- else 100/256 chance of 3rd. + TEXT_CERULEANCITY_COOLTRAINER_F1 = function(game, ow, npc, done) + local t = game.data.text + local roll = math.random(0, 255) + local text + if roll >= 180 then + text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText + elseif roll >= 100 then + text = t._CeruleanCityCooltrainerF1SlowbroPunchText + else + text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText + end + push(game, ow, npc, done, text) + end, + + -- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436) + -- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd; + -- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th. + TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done) + local t = game.data.text + local roll = math.random(0, 255) + local text + if roll >= 180 then + text = t._CeruleanCitySlowbroTookASnoozeText + elseif roll >= 120 then + text = t._CeruleanCitySlowbroIsLoafingAroundText + elseif roll >= 60 then + text = t._CeruleanCitySlowbroTurnedAwayText + else + text = t._CeruleanCitySlowbroIgnoredOrdersText + end + push(game, ow, npc, done, text) + end, + }, +} + +return M diff --git a/data/scripts/flavor/cerulean_trade_house.lua b/data/scripts/flavor/cerulean_trade_house.lua new file mode 100644 index 00000000..98965481 --- /dev/null +++ b/data/scripts/flavor/cerulean_trade_house.lua @@ -0,0 +1,22 @@ +-- CeruleanTradeHouse (pokered/scripts/CeruleanTradeHouse.asm) +-- +-- The Gambler NPC (CeruleanTradeHouseGamblerText) is a text_asm that sets +-- wWhichTrade = TRADE_FOR_LOLA and calls the DoInGameTradeDialogue predef +-- (engine/events/in_game_trades.asm). TRADE_FOR_LOLA is entry 7 in +-- data/events/trades.asm (give POLIWHIRL, get JYNX, nickname LOLA), which +-- matches data/generated/field.lua trades[7]. The Granny's text is plain +-- flavor lore (data/scripts/story.lua) -- the Gambler here owns the +-- trade, exactly as in CeruleanTradeHouse.asm. +-- EVENT_TRADED_POLIWHIRL_FOR_JYNX is the port's name for this trade's +-- wCompletedInGameTradeFlags bit (Commands.trade checks it before the +-- offer and sets it on completion). +return { + CERULEAN_TRADE_HOUSE = { + talk = { + TEXT_CERULEANTRADEHOUSE_GAMBLER = { + { "face_player" }, + { "trade", 7, "EVENT_TRADED_POLIWHIRL_FOR_JYNX" }, -- LOLA (POLIWHIRL -> JYNX) + }, + }, + }, +} diff --git a/data/scripts/flavor/cerulean_trashed_house.lua b/data/scripts/flavor/cerulean_trashed_house.lua new file mode 100644 index 00000000..a5c31b95 --- /dev/null +++ b/data/scripts/flavor/cerulean_trashed_house.lua @@ -0,0 +1,24 @@ +-- Cerulean Trashed House (pokered/scripts/CeruleanTrashedHouse.asm) +-- +-- CeruleanTrashedHouseFishingGuruText (text_asm): checks whether the +-- player is carrying TM_DIG (GetQuantityOfItemInBag) and shows one of +-- two flavor lines depending on the result -- no flags/items are ever +-- changed, it's pure branching flavor text. + +return { + CERULEAN_TRASHED_HOUSE = { + talk = { + -- CeruleanTrashedHouseFishingGuruText: + -- ld b, TM_DIG / predef GetQuantityOfItemInBag / and b + -- jr z, .no_dig_tm -> .TheyStoleATMText (player lacks TM_DIG) + -- else -> .WhatsLostIsLostText (player has TM_DIG) + TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = { + { "check_item", "TM_DIG" }, + { "jump_if_true", 4 }, + { "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" }, + { "jump", 5 }, + { "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/copycats_house_1f.lua b/data/scripts/flavor/copycats_house_1f.lua new file mode 100644 index 00000000..7d82b502 --- /dev/null +++ b/data/scripts/flavor/copycats_house_1f.lua @@ -0,0 +1,14 @@ +-- Flavor talk scripts for Copycat's House 1F (pokered/scripts/CopycatsHouse1F.asm) +return { + COPYCATS_HOUSE_1F = { + talk = { + -- CopycatsHouse1FChanseyText: text_far _CopycatsHouse1FChanseyText, then + -- text_asm plays the CHANSEY cry (ld a, CHANSEY / call PlayCry) before + -- ending. The port has no cry-playback command exposed to scripts, so + -- only the flavor text is ported. + TEXT_COPYCATSHOUSE1F_CHANSEY = { + { "show_text", "_CopycatsHouse1FChanseyText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/copycats_house_2f.lua b/data/scripts/flavor/copycats_house_2f.lua new file mode 100644 index 00000000..ac167980 --- /dev/null +++ b/data/scripts/flavor/copycats_house_2f.lua @@ -0,0 +1,26 @@ +-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F). +-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm +-- +-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL / +-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally +-- omitted here. + +return { + COPYCATS_HOUSE_2F = { + talk = { + -- CopycatsHouse2FPCText (scripts/CopycatsHouse2F.asm): + -- only shows "My Secrets!" when the player is facing UP at the + -- PC (i.e. actually looking at the screen); any other facing + -- gets the generic "Huh? Can't see!" text. + TEXT_COPYCATSHOUSE2F_PC = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local t = game.data.text + local facing = ow and ow.player and ow.player.facing + local label = (facing == "up") + and "_CopycatsHouse2FPCMySecretsText" + or "_CopycatsHouse2FPCCantSeeText" + game.stack:push(TextBox.new(game, t[label], done)) + end, + }, + }, +} diff --git a/data/scripts/flavor/game_corner.lua b/data/scripts/flavor/game_corner.lua new file mode 100644 index 00000000..2bbc8726 --- /dev/null +++ b/data/scripts/flavor/game_corner.lua @@ -0,0 +1,90 @@ +-- Flavor dialogue for GameCorner (pokered/scripts/GameCorner.asm) +-- +-- TEXT_GAMECORNER_CLERK1, TEXT_GAMECORNER_GYM_GUIDE and +-- TEXT_GAMECORNER_POSTER are already ported on data/scripts/story3.lua's +-- and story7.lua's M.GAME_CORNER tables. This file ports the three +-- remaining coin-giveaway NPCs, each a one-shot "give N coins" gated by +-- its own EVENT_GOT_*_COINS flag, the COIN CASE, and Has9990Coins room +-- to receive them (mirroring GameCornerClerk1Text's coin-case/coin-cap +-- checks in story3.lua). +local function coinGiver(opts) + return function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local Sound = require("src.core.Sound") + local t = game.data.text + local function push(label, fallback, onDone) + game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) + end + if game.save.flags[opts.event] then + push(opts.alreadyGotLabel, opts.alreadyGotFallback) + return + end + push(opts.askLabel, opts.askFallback, function() + if not game.save.inventory.COIN_CASE then + push("_GameCornerOopsForgotCoinCaseText", "Oops! Forgot the\nCOIN CASE!") + return + end + if (game.save.coins or 0) >= 9990 then + push(opts.coinCaseFullLabel, opts.coinCaseFullFallback) + return + end + game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) + game.save.flags[opts.event] = true + Sound.play(game.data, "Get_Item1") + push(opts.receivedLabel, + ("{PLAYER} received\n%d coins!"):format(opts.amount)) + end) + end +end + +return { + GAME_CORNER = { + talk = { + -- GameCornerFishingGuruText (pokered/scripts/GameCorner.asm): + -- gives 10 coins once (EVENT_GOT_10_COINS). + TEXT_GAMECORNER_FISHING_GURU = coinGiver({ + event = "EVENT_GOT_10_COINS", + amount = 10, + askLabel = "_GameCornerFishingGuruWantToPlayText", + askFallback = "Kid, do you want\nto play?", + receivedLabel = "_GameCornerFishingGuruReceived10CoinsText", + coinCaseFullLabel = "_GameCornerFishingGuruDontNeedMyCoinsText", + coinCaseFullFallback = "You don't need my\ncoins!", + alreadyGotLabel = "_GameCornerFishingGuruWinsComeAndGoText", + alreadyGotFallback = "Wins seem to come\nand go.", + }), + + -- GameCornerClerk2Text (pokered/scripts/GameCorner.asm): gives 20 + -- coins once (EVENT_GOT_20_COINS_2). + TEXT_GAMECORNER_CLERK2 = coinGiver({ + event = "EVENT_GOT_20_COINS_2", + amount = 20, + askLabel = "_GameCornerClerk2WantSomeCoinsText", + askFallback = "What's up? Want\nsome coins?", + receivedLabel = "_GameCornerClerk2Received20CoinsText", + coinCaseFullLabel = "_GameCornerClerk2YouHaveLotsOfCoinsText", + coinCaseFullFallback = "You have lots of\ncoins!", + alreadyGotLabel = "_GameCornerClerk2INeedMoreCoinsText", + 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. + TEXT_GAMECORNER_GENTLEMAN = coinGiver({ + event = "EVENT_GOT_20_COINS", + amount = 20, + askLabel = "_GameCornerGentlemanThrowingMeOffText", + askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!", + receivedLabel = "_GameCornerGentlemanReceived20CoinsText", + coinCaseFullLabel = "_GameCornerGentlemanYouGotYourOwnCoinsText", + coinCaseFullFallback = "You've got your\nown coins!", + alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText", + alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!", + }), + }, + }, +} diff --git a/data/scripts/flavor/lavender_cubone_house.lua b/data/scripts/flavor/lavender_cubone_house.lua new file mode 100644 index 00000000..a5981cbe --- /dev/null +++ b/data/scripts/flavor/lavender_cubone_house.lua @@ -0,0 +1,28 @@ +-- Lavender Cubone House flavor talk scripts. +-- Source: pokered/scripts/LavenderCuboneHouse.asm + +return { + LAVENDER_CUBONE_HOUSE = { + talk = { + -- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText, + -- then text_asm plays the CUBONE cry. Cry playback has no Commands + -- equivalent in this port, so just show the line. + TEXT_LAVENDERCUBONEHOUSE_CUBONE = { + { "face_player" }, + { "show_text", "_LavenderCuboneHouseCuboneText" }, + }, + + -- LavenderCuboneHouseBrunetteGirlText: text_asm branches on + -- EVENT_RESCUED_MR_FUJI -- before the event, she laments Cubone's + -- mother; after, she's relieved the Ghost of Pokemon Tower is gone. + TEXT_LAVENDERCUBONEHOUSE_BRUNETTE_GIRL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2 + { "jump_if_true", 6 }, -- 3 + { "show_text", "_LavenderCuboneHouseBrunetteGirlPoorCubonesMotherText" }, -- 4 + { "jump", 7 }, -- 5 + { "show_text", "_LavenderCuboneHouseBrunetteGirlGhostIsGoneText" }, -- 6 (target of jump_if_true) + }, + }, + }, +} diff --git a/data/scripts/flavor/lavender_mart.lua b/data/scripts/flavor/lavender_mart.lua new file mode 100644 index 00000000..54ba8c8d --- /dev/null +++ b/data/scripts/flavor/lavender_mart.lua @@ -0,0 +1,18 @@ +-- Flavor dialogue for LavenderMart (pokered/scripts/LavenderMart.asm) +return { + LAVENDER_MART = { + talk = { + -- LavenderMartCooltrainerMText (pokered/scripts/LavenderMart.asm): + -- before EVENT_RESCUED_MR_FUJI: talks about REVIVE; after: talks about + -- the NUGGET he found. + TEXT_LAVENDERMART_COOLTRAINER_M = { + { "face_player" }, + { "check_flag", "EVENT_RESCUED_MR_FUJI" }, + { "jump_if_true", 5 }, + { "show_text", "_LavenderMartCooltrainerMReviveText" }, + { "jump", 6 }, + { "show_text", "_LavenderMartCooltrainerMNuggetText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/lavender_town.lua b/data/scripts/flavor/lavender_town.lua new file mode 100644 index 00000000..ad3f41fd --- /dev/null +++ b/data/scripts/flavor/lavender_town.lua @@ -0,0 +1,21 @@ +-- Hand-ported text_asm dialogue for LavenderTown. +-- Source: pokered/scripts/LavenderTown.asm, pokered/text/LavenderTown.asm + +return { + LAVENDER_TOWN = { + talk = { + -- LavenderTownLittleGirlText (pokered/scripts/LavenderTown.asm): + -- asks "Do you believe in GHOSTs?" via YesNoChoice; on YES shows + -- "Really? So there are believers...", on NO shows + -- "Hahaha, I guess not. That white hand on your shoulder, it's not real." + TEXT_LAVENDERTOWN_LITTLE_GIRL = { + { "face_player" }, -- [1] + { "ask", "_LavenderTownLittleGirlDoYouBelieveInGhostsText" }, -- [2] + { "jump_if_true", 6 }, -- [3] YES -> believers text (row 6) + { "show_text", "_LavenderTownLittleGirlHaHaGuessNotText" }, -- [4] NO path + { "jump", 99 }, -- [5] end (skip believers text below) + { "show_text", "_LavenderTownLittleGirlSoThereAreBelieversText" }, -- [6] YES path + }, + }, + }, +} diff --git a/data/scripts/flavor/mr_fujis_house.lua b/data/scripts/flavor/mr_fujis_house.lua new file mode 100644 index 00000000..2502704e --- /dev/null +++ b/data/scripts/flavor/mr_fujis_house.lua @@ -0,0 +1,49 @@ +-- Mr. Fuji's House flavor NPCs (pokered/scripts/MrFujisHouse.asm). +-- TEXT_MRFUJISHOUSE_MR_FUJI and TEXT_MRFUJISHOUSE_POKEDEX are already +-- ported in data/scripts/story.lua (M.MR_FUJIS_HOUSE.talk) alongside the +-- EVENT_RESCUED_MR_FUJI onEnter repair, so they're skipped here. + +local M = {} + +M.MR_FUJIS_HOUSE = { + talk = { + -- scripts/MrFujisHouse.asm MrFujisHouseSuperNerdText: CheckEvent + -- EVENT_RESCUED_MR_FUJI branches between "he's not here" and + -- "he had been praying". + TEXT_MRFUJISHOUSE_SUPER_NERD = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2 + { "jump_if_true", 5 }, -- 3 + { "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4 + { "jump", 6 }, -- 5 + { "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6 + }, + + -- scripts/MrFujisHouse.asm MrFujisHouseLittleGirlText: CheckEvent + -- EVENT_RESCUED_MR_FUJI branches between "this is Mr. Fuji's house" + -- and "Pokemon are nice to hug". + TEXT_MRFUJISHOUSE_LITTLE_GIRL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2 + { "jump_if_true", 5 }, -- 3 + { "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4 + { "jump", 6 }, -- 5 + { "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6 + }, + + -- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then + -- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just + -- show the flavor text. + TEXT_MRFUJISHOUSE_PSYDUCK = { + { "show_text", "_MrFujisHousePsyduckText" }, + }, + + -- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then + -- PlayCry(NIDORINO). + TEXT_MRFUJISHOUSE_NIDORINO = { + { "show_text", "_MrFujisHouseNidorinoText" }, + }, + }, +} + +return M diff --git a/data/scripts/flavor/museum_1f.lua b/data/scripts/flavor/museum_1f.lua new file mode 100644 index 00000000..cd706e1a --- /dev/null +++ b/data/scripts/flavor/museum_1f.lua @@ -0,0 +1,54 @@ +-- Museum 1F flavor dialogue (pokered scripts/Museum1F.asm). +-- TEXT_MUSEUM1F_SCIENTIST1 and TEXT_MUSEUM1F_OLD_AMBER are already +-- handled in data/scripts/story2.lua (M.MUSEUM_1F, the ticket-gate +-- onStep + amber-pickup talk handler) -- not re-ported here. + +return { + MUSEUM_1F = { + talk = { + -- Museum1FGamblerText: plain text_asm, single text_far line. + TEXT_MUSEUM1F_GAMBLER = { + { "face_player" }, + { "show_text", "_Museum1FGamblerText" }, + }, + + -- Museum1FScientist2Text: CheckEvent EVENT_GOT_OLD_AMBER branch. + -- Not yet gotten -> pitch text, GiveItem OLD_AMBER (bag-full + -- refusal -> YouDontHaveSpaceText, no flag/received text), on + -- success SetEvent + predef HideObject (TOGGLE_OLD_AMBER, so the + -- OLD_AMBER sprite object on this map also disappears) + + -- ReceivedOldAmberText. Already gotten -> GetTheOldAmberCheckText. + TEXT_MUSEUM1F_SCIENTIST2 = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local Commands = require("src.script.Commands") + local t = game.data.text + local function say(label, cb) + game.stack:push(TextBox.new(game, t[label] or label, cb)) + end + + if game.save.flags.EVENT_GOT_OLD_AMBER then + say("_Museum1FScientist2GetTheOldAmberCheckText", done) + return + end + + say("_Museum1FScientist2TakeThisToAPokemonLabText", function() + if not require("src.inventory.Bag").add(game.save, "OLD_AMBER", 1) then + say("_Museum1FScientist2YouDontHaveSpaceText", done) + return + end + game.save.flags.EVENT_GOT_OLD_AMBER = true + Commands.hide_object({ save = game.save, overworld = ow, game = game }, + "MUSEUM_1F", "MUSEUM1F_OLD_AMBER") + require("src.core.Sound").play(game.data, "Get_Item1") + say("_Museum1FScientist2ReceivedOldAmberText", done) + end) + end, + + -- Museum1FScientist3Text: plain text_asm, single text_far line. + TEXT_MUSEUM1F_SCIENTIST3 = { + { "face_player" }, + { "show_text", "_Museum1FScientist3Text" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/oaks_lab.lua b/data/scripts/flavor/oaks_lab.lua new file mode 100644 index 00000000..b7d9ff0d --- /dev/null +++ b/data/scripts/flavor/oaks_lab.lua @@ -0,0 +1,41 @@ +-- Hand-ported flavor text for OaksLab (registry id OAKS_LAB). +-- Source: pokered/scripts/OaksLab.asm. These five text_asm bodies are +-- all simple "PrintText; jp TextScriptEnd" -- no flag branches, no +-- YES/NO menu -- so a one-row talk script showing the real extracted +-- text is a faithful port. (The rest of OaksLab.asm's TEXT_OAKSLAB_* +-- constants -- OAK1, the three starter poke balls, RIVAL -- are already +-- ported with full branching logic in data/scripts/oaks_lab.lua.) + +return { + OAKS_LAB = { + talk = { + -- OaksLabGirlText (scripts/OaksLab.asm) + TEXT_OAKSLAB_GIRL = { + { "face_player" }, + { "show_text", "_OaksLabGirlText" }, + }, + + -- OaksLabPokedexText, used for both the POKEDEX1 and POKEDEX2 + -- table objects (scripts/OaksLab.asm OaksLab_TextPointers) + TEXT_OAKSLAB_POKEDEX1 = { + { "face_player" }, + { "show_text", "_OaksLabPokedexText" }, + }, + TEXT_OAKSLAB_POKEDEX2 = { + { "face_player" }, + { "show_text", "_OaksLabPokedexText" }, + }, + + -- OaksLabScientistText, used for both SCIENTIST1 and SCIENTIST2 + -- (scripts/OaksLab.asm OaksLab_TextPointers) + TEXT_OAKSLAB_SCIENTIST1 = { + { "face_player" }, + { "show_text", "_OaksLabScientistText" }, + }, + TEXT_OAKSLAB_SCIENTIST2 = { + { "face_player" }, + { "show_text", "_OaksLabScientistText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua new file mode 100644 index 00000000..1d08023c --- /dev/null +++ b/data/scripts/flavor/pewter_city.lua @@ -0,0 +1,75 @@ +-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm). +-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum +-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER. +-- +-- The escort choreography (SUPER_NERD1 walking the player to the +-- museum, YOUNGSTER walking the player to the gym) is scripted NPC +-- movement + a wPewterCityCurScript state machine that steers the +-- player off-map; that part is already covered on this map by +-- story5.lua's onStep gate (walks the player back a step at the +-- east exit before EVENT_BEAT_BROCK). Here we only port the real +-- YES/NO-branched flavor text these NPCs speak when talked to. + +local M = {} + +local function text(game) return game.data.text end + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +local function ask(game, s, cb) + local ChoiceBox = require("src.ui.ChoiceBox") + push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) +end + +M.PEWTER_CITY = { + talk = { + -- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you + -- checked out the museum; YES -> fossils comment, NO -> "you have + -- to go" (which in pokered also kicks off the escort script). + TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done) + local t = text(game) + ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText + or "Did you check out\nthe MUSEUM?", function(yes) + if yes then + push(game, t._PewterCitySuperNerd1WerentThoseFossilsAmazingText + or "Weren't those\nfossils from MT.\nMOON amazing?", done) + else + push(game, t._PewterCitySuperNerd1YouHaveToGoText + or "Really?\nYou absolutely\nhave to go!", done) + end + end) + end, + + -- PewterCitySuperNerd2Text (scripts/PewterCity.asm): asks if you + -- know what he's doing; YES -> "that's right", NO -> reveals he's + -- spraying Repel to keep Pokemon out of his garden. + TEXT_PEWTERCITY_SUPER_NERD2 = function(game, ow, npc, done) + local t = text(game) + ask(game, t._PewterCitySuperNerd2DoYouKnowWhatImDoingText + or "Psssst!\nDo you know what\nI'm doing?", function(yes) + if yes then + push(game, t._PewterCitySuperNerd2ThatsRightText + or "That's right!\nIt's hard work!", done) + else + push(game, t._PewterCitySuperNerd2ImSprayingRepelText + or "I'm spraying REPEL\nto keep POKéMON\nout of my garden!", done) + end + end) + end, + + -- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow + -- me" line the youngster says when the player is stopped from + -- leaving Pewter east before beating Brock; the actual gate/step + -- block is handled by story5.lua's onStep for this map. + TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done) + local t = text(game) + push(game, t._PewterCityYoungsterYoureATrainerFollowMeText + or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done) + end, + }, +} + +return M diff --git a/data/scripts/flavor/pewter_mart.lua b/data/scripts/flavor/pewter_mart.lua new file mode 100644 index 00000000..4076ed1e --- /dev/null +++ b/data/scripts/flavor/pewter_mart.lua @@ -0,0 +1,19 @@ +-- Flavor talk scripts for PEWTER_MART (pokered/scripts/PewterMart.asm) + +return { + PEWTER_MART = { + talk = { + -- PewterMartYoungsterText: text_asm -> single text_far _PewterMartYoungsterText + TEXT_PEWTERMART_YOUNGSTER = { + { "face_player" }, + { "show_text", "_PewterMartYoungsterText" }, + }, + + -- PewterMartSuperNerdText: text_asm -> single text_far _PewterMartSuperNerdText + TEXT_PEWTERMART_SUPER_NERD = { + { "face_player" }, + { "show_text", "_PewterMartSuperNerdText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/pewter_nidoran_house.lua b/data/scripts/flavor/pewter_nidoran_house.lua new file mode 100644 index 00000000..8612b697 --- /dev/null +++ b/data/scripts/flavor/pewter_nidoran_house.lua @@ -0,0 +1,15 @@ +-- pokered/scripts/PewterNidoranHouse.asm +-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then +-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending. +-- No sound-effect command exists in Commands.lua, so we port the dialogue line +-- only; the cry SFX is cosmetic and has no gameplay effect. +return { + PEWTER_NIDORAN_HOUSE = { + talk = { + TEXT_PEWTERNIDORANHOUSE_NIDORAN = { + { "face_player" }, + { "show_text", "_PewterNidoranHouseNidoranText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/pokemon_fan_club.lua b/data/scripts/flavor/pokemon_fan_club.lua new file mode 100644 index 00000000..1efd93dd --- /dev/null +++ b/data/scripts/flavor/pokemon_fan_club.lua @@ -0,0 +1,54 @@ +-- Flavor talk scripts for POKEMON_FAN_CLUB (pokered/scripts/PokemonFanClub.asm) +-- +-- TEXT_POKEMONFANCLUB_CHAIRMAN is already ported in data/scripts/story2.lua +-- (the bike voucher chain), so it is intentionally omitted here. + +local M = {} + +M.POKEMON_FAN_CLUB = { + talk = { + -- PokemonFanClubPikachuFanText (scripts/PokemonFanClub.asm): brags + -- about her PIKACHU unless she's already "won" the boast war against + -- the SEEL fan (EVENT_PIKACHU_FAN_BOAST set), in which case she gets + -- huffy and resets it. Either way she sets the other fan's boast flag + -- so their next line is the "mine is better" retort. + TEXT_POKEMONFANCLUB_PIKACHU_FAN = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_PIKACHU_FAN_BOAST" }, -- 2 + { "jump_if_true", 7 }, -- 3 + { "show_text", "_PokemonFanClubPikachuFanNormalText" }, -- 4 + { "set_flag", "EVENT_SEEL_FAN_BOAST" }, -- 5 + { "jump", 9 }, -- 6 (skip the "mineisbetter" branch) + { "show_text", "_PokemonFanClubPikachuFanBetterText" }, -- 7 + { "clear_flag", "EVENT_PIKACHU_FAN_BOAST" }, -- 8 + }, + + -- PokemonFanClubSeelFanText (scripts/PokemonFanClub.asm): mirror of + -- the PIKACHU fan above, keyed off EVENT_SEEL_FAN_BOAST. + TEXT_POKEMONFANCLUB_SEEL_FAN = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_SEEL_FAN_BOAST" }, -- 2 + { "jump_if_true", 7 }, -- 3 + { "show_text", "_PokemonFanClubSeelFanNormalText" }, -- 4 + { "set_flag", "EVENT_PIKACHU_FAN_BOAST" }, -- 5 + { "jump", 9 }, -- 6 (skip the "mineisbetter" branch) + { "show_text", "_PokemonFanClubSeelFanBetterText" }, -- 7 + { "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8 + }, + + -- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the + -- PIKACHU itself, just a flavor line (its cry isn't playable in the + -- port's talk pipeline, so it's dropped like other cry-only lines). + TEXT_POKEMONFANCLUB_PIKACHU = { + { "show_text", "_PokemonFanClubPikachuText" }, + }, + + -- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL + -- itself, flavor line only. + TEXT_POKEMONFANCLUB_SEEL = { + { "show_text", "_PokemonFanClubSeelText" }, + }, + }, +} + +return M diff --git a/data/scripts/flavor/power_plant.lua b/data/scripts/flavor/power_plant.lua new file mode 100644 index 00000000..6282f5d1 --- /dev/null +++ b/data/scripts/flavor/power_plant.lua @@ -0,0 +1,47 @@ +-- Power Plant static encounters (scripts/PowerPlant.asm + home/trainers.asm +-- TalkToTrainer; species/levels from data/maps/objects/PowerPlant.asm). +-- +-- Each "item ball" is a disguised VOLTORB/ELECTRODE: its text_asm loads a +-- trainer header (Voltorb0..7TrainerHeader) and calls TalkToTrainer, which +-- prints PowerPlantVoltorbBattleText ("Bzzzt!") and starts a wild battle -- +-- or, when the header's EVENT_BEAT_POWER_PLANT_VOLTORB_n flag is already +-- set, prints the (identical) after-battle text and stops. Zapdos is the +-- same flow with ZapdosTrainerHeader/EVENT_BEAT_ZAPDOS, except its battle +-- text is text_far "Gyaoo!" followed by text_asm PlayCry ZAPDOS + +-- WaitForSoundToFinish. EndTrainerBattle sets the EVENT_BEAT_* flag and +-- hides the object on any non-blackout result (win, catch or flee) -- +-- static_battle mirrors that. + +-- header order in scripts/PowerPlant.asm: text_asm n uses header n-1 +local function ballMon(species, level, flag) + return { + { "show_text", "_PowerPlantVoltorbBattleText" }, -- 1 "Bzzzt!" + { "check_flag", flag }, -- 2 + { "jump_if_true", 5 }, -- 3 already beaten: text only + { "static_battle", species, level, flag }, -- 4 + } +end + +local M = {} + +M.POWER_PLANT = { + talk = { + TEXT_POWERPLANT_VOLTORB1 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_0"), + TEXT_POWERPLANT_VOLTORB2 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_1"), + TEXT_POWERPLANT_VOLTORB3 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_2"), + TEXT_POWERPLANT_ELECTRODE1 = ballMon("ELECTRODE", 43, "EVENT_BEAT_POWER_PLANT_VOLTORB_3"), + TEXT_POWERPLANT_VOLTORB4 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_4"), + TEXT_POWERPLANT_VOLTORB5 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_5"), + TEXT_POWERPLANT_ELECTRODE2 = ballMon("ELECTRODE", 43, "EVENT_BEAT_POWER_PLANT_VOLTORB_6"), + TEXT_POWERPLANT_VOLTORB6 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_7"), + TEXT_POWERPLANT_ZAPDOS = { + { "play_cry", "ZAPDOS" }, -- 1 text_asm PlayCry + { "show_text", "_PowerPlantZapdosBattleText" }, -- 2 "Gyaoo!" + { "check_flag", "EVENT_BEAT_ZAPDOS" }, -- 3 + { "jump_if_true", 6 }, -- 4 already beaten: text only + { "static_battle", "ZAPDOS", 50, "EVENT_BEAT_ZAPDOS" }, -- 5 + }, + }, +} + +return M diff --git a/data/scripts/flavor/reds_house_1f.lua b/data/scripts/flavor/reds_house_1f.lua new file mode 100644 index 00000000..6f1bbaf2 --- /dev/null +++ b/data/scripts/flavor/reds_house_1f.lua @@ -0,0 +1,25 @@ +-- Red's House 1F (pokered/scripts/RedsHouse1F.asm). +-- +-- TEXT_REDSHOUSE1F_TV (RedsHouse1FTVText): text_asm checks the player's +-- facing direction when interacting with the TV sign -- facing up shows +-- the Stand By Me movie flavor text, any other facing shows the "wrong +-- side" text (you're looking at the back of the TV). + +local TextBox = require("src.render.TextBox") + +return { + REDS_HOUSE_1F = { + talk = { + TEXT_REDSHOUSE1F_TV = function(game, ow, npc, done) + local t = game.data.text + local text + if ow.player.facing == "up" then + text = t._RedsHouse1FTVStandByMeMovieText + else + text = t._RedsHouse1FTVWrongSideText + end + game.stack:push(TextBox.new(game, text, done)) + end, + }, + }, +} diff --git a/data/scripts/flavor/route11_gate_2f.lua b/data/scripts/flavor/route11_gate_2f.lua new file mode 100644 index 00000000..742f50f2 --- /dev/null +++ b/data/scripts/flavor/route11_gate_2f.lua @@ -0,0 +1,43 @@ +-- Route 11 Gate, 2F (pokered/scripts/Route11Gate2F.asm) +-- +-- TEXT_ROUTE11GATE2F_YOUNGSTER (in-game trade) and TEXT_ROUTE11GATE2F_OAKS_AIDE +-- (Oak's Aide itemfinder handout) are not ported here; only the two window +-- signs are in scope for this pass. +-- +-- Both binocular signs use GateUpstairsScript_PrintIfFacingUp: the sign only +-- shows text when the player is facing UP (looking out the window); reading +-- it from any other direction prints nothing. + +return { + ROUTE_11_GATE_2F = { + talk = { + -- Route11Gate2FLeftBinocularsText (scripts/Route11Gate2F.asm): only + -- fires facing up; then branches on EVENT_BEAT_ROUTE12_SNORLAX to show + -- either the "big POKéMON asleep on a road" or the "beautiful view" + -- flavor text. + TEXT_ROUTE11GATE2F_LEFT_BINOCULARS = function(game, ow, npc, done) + if ow.player.facing ~= "up" then + done() + return + end + local TextBox = require("src.render.TextBox") + local label = game.save.flags.EVENT_BEAT_ROUTE12_SNORLAX + and "_Route11Gate2FLeftBinocularsNoSnorlaxText" + or "_Route11Gate2FLeftBinocularsSnorlaxText" + game.stack:push(TextBox.new(game, game.data.text[label], done)) + end, + + -- Route11Gate2FRightBinocularsText (scripts/Route11Gate2F.asm): only + -- fires facing up; describes the Cerulean-to-Lavender route via Rock + -- Tunnel. + TEXT_ROUTE11GATE2F_RIGHT_BINOCULARS = function(game, ow, npc, done) + if ow.player.facing ~= "up" then + done() + return + end + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, game.data.text["_Route11Gate2FRightBinocularsText"], done)) + end, + }, + }, +} diff --git a/data/scripts/flavor/route18_gate_2f.lua b/data/scripts/flavor/route18_gate_2f.lua new file mode 100644 index 00000000..9932827f --- /dev/null +++ b/data/scripts/flavor/route18_gate_2f.lua @@ -0,0 +1,34 @@ +-- Route 18 Gate, 2F (pokered/scripts/Route18Gate2F.asm) +-- +-- The two binocular signs only show their flavor text when the player +-- is facing up when they interact with them (GateUpstairsScript_ +-- PrintIfFacingUp checks wSpritePlayerStateData1FacingDirection == +-- SPRITE_FACING_UP; if not, it silently ends the text script without +-- printing anything). TEXT_ROUTE18GATE2F_YOUNGSTER (the in-game trade) +-- is skipped here: it's the generic DoInGameTradeDialogue path already +-- covered by the shared `trade` command/example, not map-specific logic. + +local M = {} + +local function printIfFacingUp(label) + return function(game, ow, npc, done) + if ow.player.facing ~= "up" then + done() + return + end + local TextBox = require("src.render.TextBox") + local t = game.data.text + game.stack:push(TextBox.new(game, t[label] or "", done)) + end +end + +M.ROUTE_18_GATE_2F = { + talk = { + -- Route18Gate2FLeftBinocularsText: text_far _Route18Gate2FLeftBinocularsText + TEXT_ROUTE18GATE2F_LEFT_BINOCULARS = printIfFacingUp("_Route18Gate2FLeftBinocularsText"), + -- Route18Gate2FRightBinocularsText: text_far _Route18Gate2FRightBinocularsText + TEXT_ROUTE18GATE2F_RIGHT_BINOCULARS = printIfFacingUp("_Route18Gate2FRightBinocularsText"), + }, +} + +return M diff --git a/data/scripts/flavor/route_12_gate_2f.lua b/data/scripts/flavor/route_12_gate_2f.lua new file mode 100644 index 00000000..d244ce44 --- /dev/null +++ b/data/scripts/flavor/route_12_gate_2f.lua @@ -0,0 +1,36 @@ +-- Route 12 Gate 2F binocular signs (pokered/scripts/Route12Gate2F.asm +-- Route12Gate2FLeftBinocularsText / Route12Gate2FRightBinocularsText -> +-- GateUpstairsScript_PrintIfFacingUp): the binoculars only show their +-- text when the player is facing up (looking through them from below); +-- from any other facing the sign is silently a no-op, same as the +-- original (it just clears wDoNotWaitForButtonPressAfterDisplayingText +-- and returns without printing). +-- +-- TEXT_ROUTE12GATE2F_BRUNETTE_GIRL (TM39 SWIFT gift) is intentionally +-- skipped here -- omitted, not ported by this file. + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +local function binoculars(label) + return function(game, ow, npc, done) + done = done or function() end + if ow.player.facing ~= "up" then + done() + return + end + local t = game.data.text[label] + push(game, t, done) + end +end + +return { + ROUTE_12_GATE_2F = { + talk = { + TEXT_ROUTE12GATE2F_LEFT_BINOCULARS = binoculars("_Route12Gate2FLeftBinocularsText"), + TEXT_ROUTE12GATE2F_RIGHT_BINOCULARS = binoculars("_Route12Gate2FRightBinocularsText"), + }, + }, +} diff --git a/data/scripts/flavor/route_15_gate_2f.lua b/data/scripts/flavor/route_15_gate_2f.lua new file mode 100644 index 00000000..ee1b3784 --- /dev/null +++ b/data/scripts/flavor/route_15_gate_2f.lua @@ -0,0 +1,13 @@ +-- pokered/scripts/Route15Gate2F.asm: Route15Gate2FBinocularsText +-- text_asm just calls GateUpstairsScript_PrintIfFacingUp, which prints +-- the binoculars flavor text (no flags/branches involved). +return { + ROUTE_15_GATE_2F = { + talk = { + -- Route15Gate2FBinocularsText -> .Text -> _Route15Gate2FBinocularsText + TEXT_ROUTE15GATE2F_BINOCULARS = { + { "show_text", "_Route15Gate2FBinocularsText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/route_16_fly_house.lua b/data/scripts/flavor/route_16_fly_house.lua new file mode 100644 index 00000000..563c1a30 --- /dev/null +++ b/data/scripts/flavor/route_16_fly_house.lua @@ -0,0 +1,15 @@ +-- Route 16 Fly House flavor dialogue +-- Source: pokered/scripts/Route16FlyHouse.asm, pokered/text/Route16FlyHouse.asm + +return { + ROUTE_16_FLY_HOUSE = { + talk = { + -- Route16FlyHouseFearowText: text_asm just prints the one line and + -- plays the FEAROW cry (no cry-playback command exists in this + -- port's Commands vocabulary, so only the text is ported). + TEXT_ROUTE16FLYHOUSE_FEAROW = { + { "show_text", "_Route16FlyHouseFearowText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/route_16_gate_1f.lua b/data/scripts/flavor/route_16_gate_1f.lua new file mode 100644 index 00000000..c35e1b11 --- /dev/null +++ b/data/scripts/flavor/route_16_gate_1f.lua @@ -0,0 +1,22 @@ +-- Route16Gate1F (pokered/scripts/Route16Gate1F.asm, Route16Gate1FGuardText) +-- +-- TEXT_ROUTE16GATE1F_GUARD: talking to the gate guard directly (as +-- opposed to walking into his blocking coords, which is already +-- handled by story5.lua's ROUTE_16_GATE_1F onStep bikeGateGuard). +-- text_asm calls Route16Gate1FIsBicycleInBagScript (IsItemInBag +-- BICYCLE) and shows the Cycling Road explanation if the player has a +-- BICYCLE, or the "no pedestrians allowed" text otherwise. +return { + ROUTE_16_GATE_1F = { + talk = { + TEXT_ROUTE16GATE1F_GUARD = { + { "face_player" }, + { "check_item", "BICYCLE" }, + { "jump_if_true", 5 }, + { "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" }, + { "jump", 6 }, + { "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/route_16_gate_2f.lua b/data/scripts/flavor/route_16_gate_2f.lua new file mode 100644 index 00000000..914d5126 --- /dev/null +++ b/data/scripts/flavor/route_16_gate_2f.lua @@ -0,0 +1,34 @@ +-- pokered/scripts/Route16Gate2F.asm +-- Route 16 Gate, 2F (overpass) flavor dialogue: little boy, little girl, and +-- both binocular signs. All four are plain text_far bodies (the binoculars' +-- text_asm just jumps into GateUpstairsScript_PrintIfFacingUp, which is a +-- facing-direction gate handled by the overworld sign-interaction system, +-- not extra dialogue branching), so each ports as a single-row talk script. + +return { + ROUTE_16_GATE_2F = { + talk = { + -- Route16Gate2FLittleBoyText: text_far _Route16Gate2FLittleBoyText + TEXT_ROUTE16GATE2F_LITTLE_BOY = { + { "face_player" }, + { "show_text", "_Route16Gate2FLittleBoyText" }, + }, + + -- Route16Gate2FLittleGirlText: text_far _Route16Gate2FLittleGirlText + TEXT_ROUTE16GATE2F_LITTLE_GIRL = { + { "face_player" }, + { "show_text", "_Route16Gate2FLittleGirlText" }, + }, + + -- Route16Gate2FLeftBinocularsText: text_far _Route16Gate2FLeftBinocularsText + TEXT_ROUTE16GATE2F_LEFT_BINOCULARS = { + { "show_text", "_Route16Gate2FLeftBinocularsText" }, + }, + + -- Route16Gate2FRightBinocularsText: text_far _Route16Gate2FRightBinocularsText + TEXT_ROUTE16GATE2F_RIGHT_BINOCULARS = { + { "show_text", "_Route16Gate2FRightBinocularsText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/route_18_gate_1f.lua b/data/scripts/flavor/route_18_gate_1f.lua new file mode 100644 index 00000000..dc147506 --- /dev/null +++ b/data/scripts/flavor/route_18_gate_1f.lua @@ -0,0 +1,24 @@ +-- Route18Gate1F (pokered/scripts/Route18Gate1F.asm, Route18Gate1FGuardText) +-- +-- TEXT_ROUTE18GATE1F_GUARD: talking to the gate guard directly (as +-- opposed to walking into his blocking coords, which triggers +-- TEXT_ROUTE18GATE1F_GUARD_EXCUSE_ME plus scripted movement -- not +-- ported here since that's a separate constant/flow, not a talk). +-- text_asm calls Route16Gate1FIsBicycleInBagScript (IsItemInBag +-- BICYCLE) and shows the "Cycling Road is all uphill from here" text +-- if the player has a BICYCLE, or "You need a BICYCLE for CYCLING +-- ROAD!" otherwise. +return { + ROUTE_18_GATE_1F = { + talk = { + TEXT_ROUTE18GATE1F_GUARD = { + { "face_player" }, + { "check_item", "BICYCLE" }, + { "jump_if_true", 5 }, + { "show_text", "_Route18Gate1FGuardYouNeedABicycleText" }, + { "jump", 6 }, + { "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/route_22_gate.lua b/data/scripts/flavor/route_22_gate.lua new file mode 100644 index 00000000..e3af81e0 --- /dev/null +++ b/data/scripts/flavor/route_22_gate.lua @@ -0,0 +1,25 @@ +-- Route 22 Gate guard (pokered scripts/Route22Gate.asm +-- Route22GateGuardText): text_asm branches on wObtainedBadges +-- BIT_BOULDERBADGE. Without the badge he refuses (plays SFX_DENIED, +-- "no BOULDERBADGE yet" + "the rules are rules") and the original then +-- auto-walks the player back down (Route22GateMovePlayerDownScript); +-- with the badge he waves you through. The auto-walk-back is a +-- movement/collision concern handled outside `talk` (not ported here, +-- per the task's talk-only scope) -- this only ports the guard's real +-- flavor text so the correct branch shows on repeat talks. +local M = {} + +M.ROUTE_22_GATE = { + talk = { + TEXT_ROUTE22GATE_GUARD = { + { "check_flag", "EVENT_BEAT_BROCK" }, -- 1 (BIT_BOULDERBADGE) + { "jump_if_true", 6 }, -- 2 + { "show_text", "_Route22GateGuardNoBoulderbadgeText" }, -- 3 + { "show_text", "_Route22GateGuardICantLetYouPassText" }, -- 4 + { "jump", 7 }, -- 5 (end, skip go-right-ahead) + { "show_text", "_Route22GateGuardGoRightAheadText" }, -- 6 + }, + }, +} + +return M diff --git a/data/scripts/flavor/route_23.lua b/data/scripts/flavor/route_23.lua new file mode 100644 index 00000000..313edd78 --- /dev/null +++ b/data/scripts/flavor/route_23.lua @@ -0,0 +1,51 @@ +-- Route 23's badge-gate guards (pokered scripts/Route23.asm). Each +-- guard/swimmer stands beside the staircase to Victory Road and, when +-- talked to, checks whether the player holds the badge matching that +-- step; the automatic walk-into-the-guard blocking script +-- (Route23DefaultScript / Route23CheckForBadgeScript) is not ported +-- here -- only the talk-triggered text_asm bodies, which share that +-- same subroutine (Route23CheckForBadgeScript) and text +-- (Route23YouDontHaveTheBadgeYetText / Route23OhThatIsTheBadgeText, +-- pokered/text/Route23.asm _Route23YouDontHaveTheBadgeYetText / +-- _Route23OhThatIsTheBadgeText). Once shown the badge, pokered sets +-- EVENT_PASSED__CHECK so the walk-through gate (when ported) +-- won't re-ask; we mirror that with set_flag for fidelity even though +-- no onStep gate currently reads it. + +local M = {} + +-- rows: check_item(badge) -> have it? show "Oh! That is the X!" and +-- set EVENT_PASSED_X_CHECK : show "You don't have the X yet!" +local function badgeGuard(badge, passFlag) + local subs = { RAM = badge } + return { + { "check_item", badge }, -- 1 + { "jump_if_true", 5 }, -- 2 + { "show_text", "_Route23YouDontHaveTheBadgeYetText", subs }, -- 3 + { "jump", 7 }, -- 4 (end) + { "show_text", "_Route23OhThatIsTheBadgeText", subs }, -- 5 + { "set_flag", passFlag }, -- 6 + } +end + +M.ROUTE_23 = { + talk = { + -- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK + -- -> wWhichBadge = EARTHBADGE + TEXT_ROUTE23_GUARD1 = badgeGuard("EARTHBADGE", "EVENT_PASSED_EARTHBADGE_CHECK"), + -- Route23Guard2Text: EVENT_PASSED_VOLCANOBADGE_CHECK + TEXT_ROUTE23_GUARD2 = badgeGuard("VOLCANOBADGE", "EVENT_PASSED_VOLCANOBADGE_CHECK"), + -- Route23Guard3Text: EVENT_PASSED_RAINBOWBADGE_CHECK + TEXT_ROUTE23_GUARD3 = badgeGuard("RAINBOWBADGE", "EVENT_PASSED_RAINBOWBADGE_CHECK"), + -- Route23Guard4Text: EVENT_PASSED_THUNDERBADGE_CHECK + TEXT_ROUTE23_GUARD4 = badgeGuard("THUNDERBADGE", "EVENT_PASSED_THUNDERBADGE_CHECK"), + -- Route23Guard5Text: EVENT_PASSED_CASCADEBADGE_CHECK + TEXT_ROUTE23_GUARD5 = badgeGuard("CASCADEBADGE", "EVENT_PASSED_CASCADEBADGE_CHECK"), + -- Route23Swimmer1Text: EVENT_PASSED_MARSHBADGE_CHECK + TEXT_ROUTE23_SWIMMER1 = badgeGuard("MARSHBADGE", "EVENT_PASSED_MARSHBADGE_CHECK"), + -- Route23Swimmer2Text: EVENT_PASSED_SOULBADGE_CHECK + TEXT_ROUTE23_SWIMMER2 = badgeGuard("SOULBADGE", "EVENT_PASSED_SOULBADGE_CHECK"), + }, +} + +return M diff --git a/data/scripts/flavor/route_2_trade_house.lua b/data/scripts/flavor/route_2_trade_house.lua new file mode 100644 index 00000000..6cec0d2a --- /dev/null +++ b/data/scripts/flavor/route_2_trade_house.lua @@ -0,0 +1,24 @@ +-- Route 2 Trade House (pokered/scripts/Route2TradeHouse.asm) +-- +-- TEXT_ROUTE2TRADEHOUSE_GAMEBOY_KID: text_asm sets wWhichTrade to +-- TRADE_FOR_MARCEL and calls the DoInGameTradeDialogue predef +-- (engine/events/in_game_trades.asm), i.e. the standard offer-then-trade +-- flow. TradeMons entry #2 (data/events/trades.asm) is +-- ABRA -> MR_MIME, nicknamed "MARCEL"; matches field.trades[2] in +-- data/generated/field.lua. The Scientist in this house is plain +-- flavor text (data/scripts/story.lua) -- the Game Boy Kid here owns +-- the trade, exactly as in Route2TradeHouse.asm. +-- EVENT_TRADED_ABRA_FOR_MR_MIME is the port's name for this trade's +-- wCompletedInGameTradeFlags bit (Commands.trade checks it before the +-- offer and sets it on completion). + +return { + ROUTE_2_TRADE_HOUSE = { + talk = { + TEXT_ROUTE2TRADEHOUSE_GAMEBOY_KID = { + { "face_player" }, + { "trade", 2, "EVENT_TRADED_ABRA_FOR_MR_MIME" }, -- MARCEL + }, + }, + }, +} diff --git a/data/scripts/flavor/safari_zone_gate.lua b/data/scripts/flavor/safari_zone_gate.lua new file mode 100644 index 00000000..538c2f60 --- /dev/null +++ b/data/scripts/flavor/safari_zone_gate.lua @@ -0,0 +1,23 @@ +-- pokered/scripts/SafariZoneGate.asm SafariZoneGateSafariZoneWorker2Text: +-- the second worker at the gate always asks "Is it your first time +-- here?"; YES prints the SAFARI ZONE rules, NO prints "Sorry, you're a +-- regular here!". No event flag is checked or set -- the answer is +-- purely session (it's a fresh YesNoChoice every time you talk to him). +-- +-- worker1's talk/onStep/onEnter handling already lives in +-- data/scripts/safari.lua; this file only adds worker2's flavor text. + +return { + SAFARI_ZONE_GATE = { + talk = { + TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER2 = { + {"face_player"}, + {"ask", "_SafariZoneGateSafariZoneWorker2FirstTimeHereText"}, + {"jump_if_true", 6}, + {"show_text", "_SafariZoneGateSafariZoneWorker2YoureARegularHereText"}, + {"jump", 7}, + {"show_text", "_SafariZoneGateSafariZoneWorker2SafariZoneExplanationText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/saffron_pidgey_house.lua b/data/scripts/flavor/saffron_pidgey_house.lua new file mode 100644 index 00000000..03bb4883 --- /dev/null +++ b/data/scripts/flavor/saffron_pidgey_house.lua @@ -0,0 +1,18 @@ +-- SaffronPidgeyHouse (pokered/scripts/SaffronPidgeyHouse.asm) +-- +-- TEXT_SAFFRONPIDGEYHOUSE_PIDGEY: SaffronPidgeyHousePidgeyText plays the +-- PIDGEY cry after showing its "Kurukkoo!" line (text_asm: ld a, PIDGEY / +-- call PlayCry / jp TextScriptEnd). The command vocabulary has no cry/SFX +-- command, so only the flavor text is ported here; the cry itself has no +-- equivalent to hook into. + +return { + SAFFRON_PIDGEY_HOUSE = { + talk = { + TEXT_SAFFRONPIDGEYHOUSE_PIDGEY = { + { "face_player" }, + { "show_text", "_SaffronPidgeyHousePidgeyText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/seafoam_islands_b4f.lua b/data/scripts/flavor/seafoam_islands_b4f.lua new file mode 100644 index 00000000..08a2f61e --- /dev/null +++ b/data/scripts/flavor/seafoam_islands_b4f.lua @@ -0,0 +1,29 @@ +-- Articuno (scripts/SeafoamIslandsB4F.asm SeafoamIslandsB4FArticunoText + +-- home/trainers.asm TalkToTrainer; ARTICUNO 50 from +-- data/maps/objects/SeafoamIslandsB4F.asm). +-- +-- The text_asm loads ArticunoTrainerHeader and calls TalkToTrainer: +-- SeafoamIslandsB4FArticunoBattleText is text_far "Gyaoo!" + text_asm +-- PlayCry ARTICUNO + WaitForSoundToFinish, then the wild battle starts -- +-- or, when EVENT_BEAT_ARTICUNO is already set, the (identical) +-- after-battle text prints and nothing else happens. The script also +-- switches to SCRIPT_SEAFOAMISLANDSB4F_OBJECT_MOVING3, which just routes +-- the battle end through EndTrainerBattle (set EVENT_BEAT_ARTICUNO + hide +-- the object on any non-blackout result) -- static_battle covers that. +-- The B4F current/boulder puzzle itself is data-driven via field.seafoam. + +local M = {} + +M.SEAFOAM_ISLANDS_B4F = { + talk = { + TEXT_SEAFOAMISLANDSB4F_ARTICUNO = { + { "play_cry", "ARTICUNO" }, -- 1 text_asm PlayCry + { "show_text", "_SeafoamIslandsB4FArticunoBattleText" }, -- 2 "Gyaoo!" + { "check_flag", "EVENT_BEAT_ARTICUNO" }, -- 3 + { "jump_if_true", 6 }, -- 4 already beaten: text only + { "static_battle", "ARTICUNO", 50, "EVENT_BEAT_ARTICUNO" }, -- 5 + }, + }, +} + +return M diff --git a/data/scripts/flavor/silph_co_10f.lua b/data/scripts/flavor/silph_co_10f.lua new file mode 100644 index 00000000..33c8c5ff --- /dev/null +++ b/data/scripts/flavor/silph_co_10f.lua @@ -0,0 +1,22 @@ +-- Silph Co. 10F flavor dialogue. +-- Registered standalone (not via init.lua by this agent); returns the +-- talk table for SILPH_CO_10F. + +return { + SILPH_CO_10F = { + talk = { + -- pokered/scripts/SilphCo10F.asm SilphCo10FSilphWorkerFText: + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI branches between the + -- "I'm scared" line (Giovanni not yet beaten) and the "please + -- keep quiet about my crying" line (after Giovanni is beaten). + TEXT_SILPHCO10F_SILPH_WORKER_F = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo10FSilphWorkerFImScaredText" }, + { "jump", 6 }, + { "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_3f.lua b/data/scripts/flavor/silph_co_3f.lua new file mode 100644 index 00000000..4813fb71 --- /dev/null +++ b/data/scripts/flavor/silph_co_3f.lua @@ -0,0 +1,20 @@ +-- Flavor talk scripts for Silph Co. 3F. +-- Source: pokered/scripts/SilphCo3F.asm, pokered/text/SilphCo3F.asm + +return { + SILPH_CO_3F = { + talk = { + -- SilphCo3FSilphWorkerMText (scripts/SilphCo3F.asm): + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI -> + -- set: _SilphCo3FSilphWorkerMYouSavedUsText + -- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText + TEXT_SILPHCO3F_SILPH_WORKER_M = { + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 4 }, + { "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" }, + { "jump", 5 }, + { "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_4f.lua b/data/scripts/flavor/silph_co_4f.lua new file mode 100644 index 00000000..3ac8d112 --- /dev/null +++ b/data/scripts/flavor/silph_co_4f.lua @@ -0,0 +1,17 @@ +-- pokered/scripts/SilphCo4F.asm: SilphCo4FSilphWorkerMText +-- Uses SilphCo6FBeatGiovanniPrintDEOrPrintHLScript: if EVENT_BEAT_SILPH_CO_GIOVANNI +-- is set, show the "Team Rocket is gone?" line; otherwise show the "hiding" line. +return { + SILPH_CO_4F = { + talk = { + TEXT_SILPHCO4F_SILPH_WORKER_M = { + {"face_player"}, + {"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"}, + {"jump_if_true", 5}, + {"show_text", "_SilphCo4FSilphWorkerMImHidingText"}, + {"jump", 6}, + {"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_5f.lua b/data/scripts/flavor/silph_co_5f.lua new file mode 100644 index 00000000..79213620 --- /dev/null +++ b/data/scripts/flavor/silph_co_5f.lua @@ -0,0 +1,17 @@ +-- pokered/scripts/SilphCo5F.asm: SilphCo5FSilphWorkerMText +-- Uses SilphCo6FBeatGiovanniPrintDEOrPrintHLScript: if EVENT_BEAT_SILPH_CO_GIOVANNI +-- is set, show the "You're our hero" line; otherwise show the "That's you right?" line. +return { + SILPH_CO_5F = { + talk = { + TEXT_SILPHCO5F_SILPH_WORKER_M = { + {"face_player"}, + {"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"}, + {"jump_if_true", 5}, + {"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"}, + {"jump", 6}, + {"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_6f.lua b/data/scripts/flavor/silph_co_6f.lua new file mode 100644 index 00000000..daeb4d42 --- /dev/null +++ b/data/scripts/flavor/silph_co_6f.lua @@ -0,0 +1,61 @@ +-- Silph Co. 6F worker flavor dialogue. +-- pokered/scripts/SilphCo6F.asm: SilphCo6FSilphWorkerM1Text/M2Text/M3Text/ +-- F1Text/F2Text all funnel through SilphCo6FBeatGiovanniPrintDEOrPrintHLScript, +-- which checks EVENT_BEAT_SILPH_CO_GIOVANNI and prints the "before" text (hl) +-- if not yet set, or the "after" text (de) once Giovanni has been beaten. + +return { + SILPH_CO_6F = { + talk = { + -- SilphCo6FSilphWorkerM1Text + TEXT_SILPHCO6F_SILPH_WORKER_M1 = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" }, + { "jump", 6 }, + { "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" }, + }, + + -- SilphCo6FSilphWorkerM2Text + TEXT_SILPHCO6F_SILPH_WORKER_M2 = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" }, + { "jump", 6 }, + { "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" }, + }, + + -- SilphCo6FSilphWorkerF1Text + TEXT_SILPHCO6F_SILPH_WORKER_F1 = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" }, + { "jump", 6 }, + { "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" }, + }, + + -- SilphCo6FSilphWorkerF2Text + TEXT_SILPHCO6F_SILPH_WORKER_F2 = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" }, + { "jump", 6 }, + { "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" }, + }, + + -- SilphCo6FSilphWorkerM3Text + TEXT_SILPHCO6F_SILPH_WORKER_M3 = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 5 }, + { "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" }, + { "jump", 6 }, + { "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_7f.lua b/data/scripts/flavor/silph_co_7f.lua new file mode 100644 index 00000000..1c56cb5a --- /dev/null +++ b/data/scripts/flavor/silph_co_7f.lua @@ -0,0 +1,44 @@ +-- Flavor talk scripts for Silph Co. 7F. +-- Source: pokered/scripts/SilphCo7F.asm, pokered/text/SilphCo7F.asm + +return { + SILPH_CO_7F = { + talk = { + -- SilphCo7FSilphWorkerM2Text (scripts/SilphCo7F.asm): + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI -> + -- not set: _SilphCo7FSilphWorkerM2AfterTheMasterBallText + -- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText + TEXT_SILPHCO7F_SILPH_WORKER_M2 = { + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 4 }, + { "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" }, + { "jump", 5 }, + { "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" }, + }, + + -- SilphCo7FSilphWorkerM3Text (scripts/SilphCo7F.asm): + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI -> + -- not set: _SilphCo7FSilphWorkerM3ItWouldBeBadText + -- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText + TEXT_SILPHCO7F_SILPH_WORKER_M3 = { + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 4 }, + { "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" }, + { "jump", 5 }, + { "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" }, + }, + + -- SilphCo7FSilphWorkerM4Text (scripts/SilphCo7F.asm): + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI -> + -- not set: _SilphCo7FSilphWorkerM4ItsReallyDangerousHereText + -- set: _SilphCo7FSilphWorkerM4SafeAtLastText + TEXT_SILPHCO7F_SILPH_WORKER_M4 = { + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 4 }, + { "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" }, + { "jump", 5 }, + { "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_8f.lua b/data/scripts/flavor/silph_co_8f.lua new file mode 100644 index 00000000..13ca3913 --- /dev/null +++ b/data/scripts/flavor/silph_co_8f.lua @@ -0,0 +1,20 @@ +-- Flavor talk scripts for Silph Co. 8F. +-- Source: pokered/scripts/SilphCo8F.asm, pokered/text/SilphCo8F.asm + +return { + SILPH_CO_8F = { + talk = { + -- SilphCo8FSilphWorkerMText (scripts/SilphCo8F.asm): + -- CheckEvent EVENT_BEAT_SILPH_CO_GIOVANNI -> + -- not set: _SilphCo8FSilphWorkerMSilphIsFinishedText + -- set: _SilphCo8FSilphWorkerMThanksForSavingUsText + TEXT_SILPHCO8F_SILPH_WORKER_M = { + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 4 }, + { "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" }, + { "jump", 5 }, + { "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/silph_co_9f.lua b/data/scripts/flavor/silph_co_9f.lua new file mode 100644 index 00000000..e04af559 --- /dev/null +++ b/data/scripts/flavor/silph_co_9f.lua @@ -0,0 +1,24 @@ +-- Silph Co. 9F (registry id: SILPH_CO_9F) +-- Source: pokered/scripts/SilphCo9F.asm, pokered/text/SilphCo9F.asm + +return { + SILPH_CO_9F = { + talk = { + -- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm): + -- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows + -- "You look tired..." then "Don't give up!"; after the event, just + -- says thanks. Nurse texts are not in data/generated/text.lua, so + -- the exact pokered/text/SilphCo9F.asm strings are used as literals. + TEXT_SILPHCO9F_NURSE = { + { "face_player" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", 8 }, + { "show_text", "You look tired!\nYou should take a\nquick nap!" }, + { "heal_party" }, + { "show_text", "Don't give up!" }, + { "jump", 9 }, + { "show_text", "Thank you so\nmuch!" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/ss_anne_1f_rooms.lua b/data/scripts/flavor/ss_anne_1f_rooms.lua new file mode 100644 index 00000000..bde124b5 --- /dev/null +++ b/data/scripts/flavor/ss_anne_1f_rooms.lua @@ -0,0 +1,12 @@ +-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText +-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported) +return { + SS_ANNE_1F_ROOMS = { + talk = { + TEXT_SSANNE1FROOMS_WIGGLYTUFF = { + {"face_player"}, + {"show_text", "_SSAnne1FRoomsWigglytuffText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/ss_anne_2f_rooms.lua b/data/scripts/flavor/ss_anne_2f_rooms.lua new file mode 100644 index 00000000..8f7f4fda --- /dev/null +++ b/data/scripts/flavor/ss_anne_2f_rooms.lua @@ -0,0 +1,62 @@ +-- SS Anne, 2F rooms (pokered/scripts/SSAnne2FRooms.asm) +-- +-- All ported constants below are plain text_asm bodies: PrintText of a +-- single text_far, no CheckEvent branching, no YES/NO menu. The +-- trainers (Gentleman1/2, Fisher, CooltrainerF) and the PickUpItem +-- rows (MAX_ETHER, RARE_CANDY) are skipped -- trainers are handled by +-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer +-- system, and the item pickups carry no talk text of their own here. +-- +-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for +-- SNORLAX after the text box (DisplayPokedex in the original), which +-- has no equivalent talk-script hook in this port (DexEntryMenu has +-- no done-callback the way other pushed UI states do) -- only the +-- flavor line is ported here. +return { + SS_ANNE_2F_ROOMS = { + talk = { + -- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text) + -- (then DisplayPokedex SNORLAX -- not ported, see note above) + TEXT_SSANNE2FROOMS_GENTLEMAN3 = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsGentleman3Text" }, + }, + + -- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text) + TEXT_SSANNE2FROOMS_GENTLEMAN4 = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsGentleman4Text" }, + }, + + -- SSAnne2FRoomsGentleman5Text: PrintText(_SSAnne2FRoomsGentleman5Text) + TEXT_SSANNE2FROOMS_GENTLEMAN5 = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsGentleman5Text" }, + }, + + -- SSAnne2FRoomsGrampsText: PrintText(_SSAnne2FRoomsGrampsText) + TEXT_SSANNE2FROOMS_GRAMPS = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsGrampsText" }, + }, + + -- SSAnne2FRoomsLittleBoyText: PrintText(_SSAnne2FRoomsLittleBoyText) + TEXT_SSANNE2FROOMS_LITTLE_BOY = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsLittleBoyText" }, + }, + + -- SSAnne2FRoomsBrunetteGirlText: PrintText(_SSAnne2FRoomsBrunetteGirlText) + TEXT_SSANNE2FROOMS_BRUNETTE_GIRL = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsBrunetteGirlText" }, + }, + + -- SSAnne2FRoomsBeautyText: PrintText(_SSAnne2FRoomsBeautyText) + TEXT_SSANNE2FROOMS_BEAUTY = { + { "face_player" }, + { "show_text", "_SSAnne2FRoomsBeautyText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/ss_anne_b1f_rooms.lua b/data/scripts/flavor/ss_anne_b1f_rooms.lua new file mode 100644 index 00000000..76f5bc3c --- /dev/null +++ b/data/scripts/flavor/ss_anne_b1f_rooms.lua @@ -0,0 +1,14 @@ +-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText +-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry` +-- (cry playback has no equivalent Commands.lua verb in this port, so only +-- the flavor text is ported). +return { + SS_ANNE_B1F_ROOMS = { + talk = { + TEXT_SSANNEB1FROOMS_MACHOKE = { + { "face_player" }, + { "show_text", "_SSAnneB1FRoomsMachokeText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/ss_anne_kitchen.lua b/data/scripts/flavor/ss_anne_kitchen.lua new file mode 100644 index 00000000..f3180ce6 --- /dev/null +++ b/data/scripts/flavor/ss_anne_kitchen.lua @@ -0,0 +1,43 @@ +-- SS Anne kitchen cook flavor dialogue. +-- pokered/scripts/SSAnneKitchen.asm SSAnneKitchenCook7Text + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +return { + SS_ANNE_KITCHEN = { + talk = { + -- pokered/scripts/SSAnneKitchen.asm SSAnneKitchenCook7Text (text_asm): + -- always shows the "main course is" lead-in + -- (_SSAnneKitchenCook7MainCourseIsText), then rolls hRandomAdd to + -- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4 + -- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak. + -- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText / + -- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted + -- into data/generated/text.lua (no leading underscore in + -- pokered/text/SSAnneKitchen.asm), so their literal strings are + -- ported here verbatim. + TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done) + local t = game.data.text + push(game, t._SSAnneKitchenCook7MainCourseIsText + or "Er-hem! Indeed I\nam le CHEF!\fLe main course is", function() + local roll = math.random(1, 4) + local dish + if roll <= 2 then + -- bit 7 of hRandomAdd set (~50%) + dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!" + elseif roll == 3 then + -- bit 4 set, bit 7 clear (~25%) + dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear." + else + -- neither bit set (~25%) + dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?" + end + push(game, dish, done) + end) + end, + }, + }, +} diff --git a/data/scripts/flavor/vermilion_city.lua b/data/scripts/flavor/vermilion_city.lua new file mode 100644 index 00000000..c31d36fd --- /dev/null +++ b/data/scripts/flavor/vermilion_city.lua @@ -0,0 +1,28 @@ +-- Hand-ported text_asm dialogue for VermilionCity NPCs. +-- pokered/scripts/VermilionCity.asm + +return { + VERMILION_CITY = { + talk = { + -- VermilionCityGambler1Text: before the S.S. Anne departs he asks + -- if you saw it moored in the harbor; after it leaves he remarks + -- that it's gone and will return in about a year. + -- (pokered/scripts/VermilionCity.asm) + TEXT_VERMILIONCITY_GAMBLER1 = { + { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 1 + { "jump_if_true", 5 }, -- 2 + { "show_text", "_VermilionCityGambler1DidYouSeeText" }, -- 3 + { "jump", 6 }, -- 4 + { "show_text", "_VermilionCityGambler1SSAnneDepartedText" }, -- 5 + }, + + -- VermilionCityMachopText: cries out, then follows up with a + -- second line about stomping the land flat. + -- (pokered/scripts/VermilionCity.asm) + TEXT_VERMILIONCITY_MACHOP = { + { "show_text", "_VermilionCityMachopText" }, -- 1 + { "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2 + }, + }, + }, +} diff --git a/data/scripts/flavor/vermilion_pidgey_house.lua b/data/scripts/flavor/vermilion_pidgey_house.lua new file mode 100644 index 00000000..043c2c6b --- /dev/null +++ b/data/scripts/flavor/vermilion_pidgey_house.lua @@ -0,0 +1,16 @@ +-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText +-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY +-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before +-- TextScriptEnd. This port has no cry-playback command, so only the +-- flavor line is ported. + +return { + VERMILION_PIDGEY_HOUSE = { + talk = { + TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = { + {"face_player"}, + {"show_text", "_VermilionPidgeyHousePidgeyText"}, + }, + }, + }, +} diff --git a/data/scripts/flavor/victory_road_2f.lua b/data/scripts/flavor/victory_road_2f.lua new file mode 100644 index 00000000..c4af3259 --- /dev/null +++ b/data/scripts/flavor/victory_road_2f.lua @@ -0,0 +1,27 @@ +-- Moltres (scripts/VictoryRoad2F.asm VictoryRoad2FMoltresText + +-- home/trainers.asm TalkToTrainer; MOLTRES 50 from +-- data/maps/objects/VictoryRoad2F.asm). +-- +-- The text_asm loads MoltresTrainerHeader and calls TalkToTrainer: +-- VictoryRoad2FMoltresBattleText is text_far "Gyaoo!" + text_asm +-- PlayCry MOLTRES + WaitForSoundToFinish, then the wild battle starts -- +-- or, when EVENT_BEAT_MOLTRES is already set, the (identical) +-- after-battle text prints and nothing else happens. EndTrainerBattle +-- sets EVENT_BEAT_MOLTRES and hides the object on any non-blackout +-- result (win, catch or flee) -- static_battle mirrors that. + +local M = {} + +M.VICTORY_ROAD_2F = { + talk = { + TEXT_VICTORYROAD2F_MOLTRES = { + { "play_cry", "MOLTRES" }, -- 1 text_asm PlayCry + { "show_text", "_VictoryRoad2FMoltresBattleText" }, -- 2 "Gyaoo!" + { "check_flag", "EVENT_BEAT_MOLTRES" }, -- 3 + { "jump_if_true", 6 }, -- 4 already beaten: text only + { "static_battle", "MOLTRES", 50, "EVENT_BEAT_MOLTRES" }, -- 5 + }, + }, +} + +return M diff --git a/data/scripts/flavor/viridian_city.lua b/data/scripts/flavor/viridian_city.lua new file mode 100644 index 00000000..6974f1d2 --- /dev/null +++ b/data/scripts/flavor/viridian_city.lua @@ -0,0 +1,115 @@ +-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm). +-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN. +-- +-- Not ported here (already handled elsewhere / not talk-reachable): +-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a +-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk. +-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED -- +-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript / +-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the +-- gates themselves are already implemented via story5.lua's onStep +-- chain (viridianOldManStep / viridianGymLock) for this map. +-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_ +-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full +-- scripted-battle cutscene outside this task's Commands vocabulary +-- (no static_battle-style "battle a scripted old-man WEEDLE" command +-- exists); we port the real YES/NO branch text he speaks but the +-- "yes" branch here just shows the "I'll show you how" line rather +-- than actually starting the minigame, since that machinery isn't +-- ported to this map yet. + +local M = {} + +local function text(game) return game.data.text end + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +local function ask(game, s, cb) + local ChoiceBox = require("src.ui.ChoiceBox") + push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) +end + +M.VIRIDIAN_CITY = { + talk = { + -- ViridianCityGambler1Text (scripts/ViridianCity.asm): normally + -- comments that the gym is "always closed"; once the 7th badge is + -- earned (badges == ~EARTHBADGE) but Giovanni hasn't been beaten + -- yet, he instead says the gym leader returned. + TEXT_VIRIDIANCITY_GAMBLER1 = function(game, ow, npc, done) + local t = text(game) + local sevenBadges = game.save.inventory and + game.save.inventory.BOULDERBADGE and game.save.inventory.CASCADEBADGE + and game.save.inventory.THUNDERBADGE and game.save.inventory.RAINBOWBADGE + and game.save.inventory.SOULBADGE and game.save.inventory.MARSHBADGE + and game.save.inventory.VOLCANOBADGE + -- (pokered checks EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI; the port's flag for + -- that win is EVENT_BEAT_GIOVANNI, set by victories.lua OPP_GIOVANNI#3) + if sevenBadges and not (game.save.flags and game.save.flags.EVENT_BEAT_GIOVANNI) then + push(game, t._ViridianCityGambler1GymLeaderReturnedText + or "VIRIDIAN GYM's\nLEADER returned!", done) + else + push(game, t._ViridianCityGambler1GymAlwaysClosedText + or "This POKéMON GYM\nis always closed.\nI wonder who the\nLEADER is?", done) + end + end, + + -- ViridianCityYoungster2Text (scripts/ViridianCity.asm): asks if + -- you want to know about the two kinds of caterpillar Pokemon; + -- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!". + -- ViridianCityYoungster2OkThenText and + -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are + -- defined without a leading underscore in pokered/text/ViridianCity.asm + -- and aren't present in data/generated/text.lua, so we fall back to + -- the literal strings from pokered. + TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done) + local t = text(game) + ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText + or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes) + if yes then + push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done) + else + push(game, "Oh, OK then!", done) + end + end) + end, + + -- ViridianCityGirlText (scripts/ViridianCity.asm): before the + -- player has the Pokedex she scolds her grandpa for being mean + -- (he hasn't had his coffee yet); after EVENT_GOT_POKEDEX she talks + -- about the winding trail through Viridian Forest to Pewter. + TEXT_VIRIDIANCITY_GIRL = function(game, ow, npc, done) + local t = text(game) + if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then + push(game, t._ViridianCityGirlWhenIGoShopText + or "When I go shop in\nPEWTER CITY, I\nhave to take the\nwinding trail in\nVIRIDIAN FOREST.", done) + else + push(game, t._ViridianCityGirlHasntHadHisCoffeeYetText + or "Oh Grandpa! Don't\nbe so mean!\nHe hasn't had his\ncoffee yet.", done) + end + end, + + -- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had + -- his coffee, he asks (YES/NO) whether you want to learn how to + -- catch Pokemon. YES leads into the catch-training minigame + -- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported -- + -- see file header); NO just brushes you off ("Time is money..."). + TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done) + local t = text(game) + ask(game, t._ViridianCityOldManHadMyCoffeeNowText + or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes) + if yes then + push(game, t._ViridianCityOldManKnowHowToCatchPokemonText + or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done) + else + push(game, t._ViridianCityOldManTimeIsMoneyText + or "Time is money...\nGo along then.", done) + end + end) + end, + }, +} + +return M diff --git a/data/scripts/flavor/viridian_nickname_house.lua b/data/scripts/flavor/viridian_nickname_house.lua new file mode 100644 index 00000000..cee7b89f --- /dev/null +++ b/data/scripts/flavor/viridian_nickname_house.lua @@ -0,0 +1,15 @@ +-- Viridian Nickname House (pokered/scripts/ViridianNicknameHouse.asm). + +return { + VIRIDIAN_NICKNAME_HOUSE = { + talk = { + -- ViridianNicknameHouseSpearowText: text_asm that shows the text + -- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The + -- cry playback isn't in the talk-script command vocabulary, so + -- only the flavor line is ported here. + TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = { + { "show_text", "_ViridianNicknameHouseSpearowText" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/wardens_house.lua b/data/scripts/flavor/wardens_house.lua new file mode 100644 index 00000000..1b36ef97 --- /dev/null +++ b/data/scripts/flavor/wardens_house.lua @@ -0,0 +1,20 @@ +-- pokered/scripts/WardensHouse.asm: WardensHouseDisplayText branches on +-- hTextID (TEXT_WARDENSHOUSE_DISPLAY_LEFT vs _RIGHT) to pick which of the +-- two display case texts to print. TEXT_WARDENSHOUSE_WARDEN is already +-- ported in data/scripts/story.lua (M.WARDENS_HOUSE); not re-ported here. +return { + WARDENS_HOUSE = { + talk = { + -- left case: photos and fossils + TEXT_WARDENSHOUSE_DISPLAY_LEFT = { + { "face_player" }, -- 1 + { "show_text", "_WardensHouseDisplayPhotosAndFossilsText" }, -- 2 + }, + -- right case: old pokemon merchandise + TEXT_WARDENSHOUSE_DISPLAY_RIGHT = { + { "face_player" }, -- 1 + { "show_text", "_WardensHouseDisplayMerchandiseText" }, -- 2 + }, + }, + }, +} diff --git a/data/scripts/flavor_all.lua b/data/scripts/flavor_all.lua new file mode 100644 index 00000000..efb5d45f --- /dev/null +++ b/data/scripts/flavor_all.lua @@ -0,0 +1,75 @@ +-- Auto-generated index of the ported text_asm flavor talk scripts +-- (Workstream A backlog). Each data/scripts/flavor/.lua returns +-- { MAP_ID = { talk = {...} } } for one map; this combines them into a +-- single table that data/scripts/init.lua merges into the registry +-- (talk tables merge per TEXT constant, like the story files). +local files = { + "data.scripts.flavor.bike_shop", + "data.scripts.flavor.celadon_city", + "data.scripts.flavor.celadon_mansion_1f", + "data.scripts.flavor.celadon_mansion_3f", + "data.scripts.flavor.cerulean_badge_house", + "data.scripts.flavor.cerulean_cave_b1f", + "data.scripts.flavor.cerulean_city", + "data.scripts.flavor.cerulean_trade_house", + "data.scripts.flavor.cerulean_trashed_house", + "data.scripts.flavor.copycats_house_1f", + "data.scripts.flavor.copycats_house_2f", + "data.scripts.flavor.game_corner", + "data.scripts.flavor.lavender_cubone_house", + "data.scripts.flavor.lavender_mart", + "data.scripts.flavor.lavender_town", + "data.scripts.flavor.mr_fujis_house", + "data.scripts.flavor.museum_1f", + "data.scripts.flavor.oaks_lab", + "data.scripts.flavor.pewter_city", + "data.scripts.flavor.pewter_mart", + "data.scripts.flavor.pewter_nidoran_house", + "data.scripts.flavor.pokemon_fan_club", + "data.scripts.flavor.power_plant", + "data.scripts.flavor.reds_house_1f", + "data.scripts.flavor.route11_gate_2f", + "data.scripts.flavor.route18_gate_2f", + "data.scripts.flavor.route_12_gate_2f", + "data.scripts.flavor.route_15_gate_2f", + "data.scripts.flavor.route_16_fly_house", + "data.scripts.flavor.route_16_gate_1f", + "data.scripts.flavor.route_16_gate_2f", + "data.scripts.flavor.route_18_gate_1f", + "data.scripts.flavor.route_22_gate", + "data.scripts.flavor.route_23", + "data.scripts.flavor.route_2_trade_house", + "data.scripts.flavor.safari_zone_gate", + "data.scripts.flavor.saffron_pidgey_house", + "data.scripts.flavor.seafoam_islands_b4f", + "data.scripts.flavor.silph_co_10f", + "data.scripts.flavor.silph_co_3f", + "data.scripts.flavor.silph_co_4f", + "data.scripts.flavor.silph_co_5f", + "data.scripts.flavor.silph_co_6f", + "data.scripts.flavor.silph_co_7f", + "data.scripts.flavor.silph_co_8f", + "data.scripts.flavor.silph_co_9f", + "data.scripts.flavor.ss_anne_1f_rooms", + "data.scripts.flavor.ss_anne_2f_rooms", + "data.scripts.flavor.ss_anne_b1f_rooms", + "data.scripts.flavor.ss_anne_kitchen", + "data.scripts.flavor.vermilion_city", + "data.scripts.flavor.vermilion_pidgey_house", + "data.scripts.flavor.victory_road_2f", + "data.scripts.flavor.viridian_city", + "data.scripts.flavor.viridian_nickname_house", + "data.scripts.flavor.wardens_house", +} + +local M = {} +for _, f in ipairs(files) do + for mapId, mod in pairs(require(f)) do + if M[mapId] and M[mapId].talk and mod.talk then + for k, v in pairs(mod.talk) do M[mapId].talk[k] = v end + else + M[mapId] = mod + end + end +end +return M diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua new file mode 100644 index 00000000..c94484e3 --- /dev/null +++ b/data/scripts/gyms.lua @@ -0,0 +1,148 @@ +-- Gym metadata for the statue hidden events (data/maps/badge_maps.asm +-- gives map -> badge; each gym's script carries its .CityName / +-- .LeaderName strings, e.g. scripts/VermilionGym.asm +-- LoadGymLeaderAndCityName). +-- +-- The module is also merged into the map-script registry (see +-- data/scripts/init.lua), so gym maps can carry hand-ported `talk` +-- scripts alongside the statue metadata. + +local M = { + PEWTER_GYM = { city = "PEWTER CITY", leader = "BROCK", badge = "BOULDERBADGE" }, + CERULEAN_GYM = { city = "CERULEAN CITY", leader = "MISTY", badge = "CASCADEBADGE" }, + VERMILION_GYM = { city = "VERMILION CITY", leader = "LT.SURGE", badge = "THUNDERBADGE" }, + CELADON_GYM = { city = "CELADON CITY", leader = "ERIKA", badge = "RAINBOWBADGE" }, + FUCHSIA_GYM = { city = "FUCHSIA CITY", leader = "KOGA", badge = "SOULBADGE" }, + SAFFRON_GYM = { city = "SAFFRON CITY", leader = "SABRINA", badge = "MARSHBADGE" }, + CINNABAR_GYM = { city = "CINNABAR ISLAND", leader = "BLAINE", badge = "VOLCANOBADGE" }, + VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" }, +} + +-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent +-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints +-- _PewterGymBrockPreBattleText and engages the leader battle +-- (engageTrainer shows that same pre-battle text via resolveText; the +-- badge/TM34 rewards and EVENT_BEAT_BROCK come from +-- data/scripts/victories.lua OPP_BROCK#1). After the badge his +-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to +-- the GYM in CERULEAN..."). The original's middle branch (beat but +-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is +-- unreachable in the port: the TM is granted with the victory. +M.PEWTER_GYM.talk = { + TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done) + if game.save.flags.EVENT_BEAT_BROCK then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._PewterGymBrockPostBattleAdviceText + or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done)) + else + ow:engageTrainer(npc, done) + end + end, +} + +-- The other leaders' text_asm bodies all follow Brock's shape +-- (scripts/CeruleanGym.asm CeruleanGymMistyText ... scripts/ViridianGym.asm +-- ViridianGymGiovanniText): CheckEvent EVENT_BEAT_ -- before the +-- badge print the pre-battle text and engage the leader battle +-- (engageTrainer shows that same pre-battle text via resolveText; the +-- badge/TM rewards and the beat flag come from data/scripts/victories.lua) +-- -- and once beaten print the post-battle advice text. As with Brock, +-- the originals' middle branch (beaten but the TM not yet handed over, +-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is +-- granted with the victory. +-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc, +-- done) and must call done() itself once whatever it's doing (e.g. a fade +-- around a HideObject) finishes, rather than having it invoked +-- automatically. Only Giovanni's farewell uses this. +local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice) + return function(game, ow, npc, done) + if game.save.flags[beatFlag] then + local TextBox = require("src.render.TextBox") + local finish = done + if afterAdvice then + finish = function() + afterAdvice(game, ow, npc, done) + end + end + game.stack:push(TextBox.new(game, + game.data.text[adviceLabel] or fallback, finish)) + else + ow:engageTrainer(npc, done) + end + end +end + +-- scripts/CeruleanGym.asm CeruleanGymMistyText .afterBeat: Misty has no +-- separate advice label -- her repeat dialogue is the TM11 explanation +-- (.TM11ExplanationText). +M.CERULEAN_GYM.talk = { + TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY", + "_CeruleanGymMistyTM11ExplanationText", + "TM11 teaches\nBUBBLEBEAM!"), +} + +-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already +M.VERMILION_GYM.talk = { + TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE", + "_VermilionGymLTSurgePostBattleAdviceText", + "A little word of\nadvice, kid!"), +} + +-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat +M.CELADON_GYM.talk = { + TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA", + "_CeladonGymErikaPostBattleAdviceText", + "You are cataloging\nPOKéMON? I must\nsay I'm impressed."), +} + +-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat +M.FUCHSIA_GYM.talk = { + TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA", + "_FuchsiaGymKogaPostBattleAdviceText", + "When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"), +} + +-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat +M.SAFFRON_GYM.talk = { + TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA", + "_SaffronGymSabrinaPostBattleAdviceText", + "Everyone has\npsychic power!\nPeople just don't\nrealize it!"), +} + +-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat +M.CINNABAR_GYM.talk = { + TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE", + "_CinnabarGymBlainePostBattleAdviceText", + "FIRE BLAST is the\nultimate fire\ntechnique!"), +} + +-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the +-- farewell speech Giovanni leaves for good -- the original fades to +-- black (GBFadeOutToBlack), HideObject TOGGLE_VIRIDIAN_GYM_GIOVANNI while +-- the screen is black, then fades back in (GBFadeInFromBlack). The port +-- reuses src/render/Transition.lua (the same fade-out/callback/fade-in +-- primitive warps and PartyMenu field moves push) so HideObject fires at +-- its onMidpoint, between the two fades, instead of as a bare disappearance +-- when the text box closes. The objectToggles entry persists in the save, +-- so he stays gone on re-entry. pokered's beat flag is +-- EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI; the port's equivalent set on winning +-- that battle is EVENT_BEAT_GIOVANNI (data/scripts/victories.lua +-- OPP_GIOVANNI#3). +M.VIRIDIAN_GYM.talk = { + TEXT_VIRIDIANGYM_GIOVANNI = leaderTalk("EVENT_BEAT_GIOVANNI", + "_ViridianGymGiovanniPostBattleAdviceText", + "Let us meet again\nsome day!\nFarewell!", + function(game, ow, npc, done) + local Transition = require("src.render.Transition") + game.stack:push(Transition.new(game, function() + local ok, Commands = pcall(require, "src.script.Commands") + if ok and Commands.hide_object then + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI") + end + end, done)) + end), +} + +return M diff --git a/data/scripts/init.lua b/data/scripts/init.lua new file mode 100644 index 00000000..9bf6b86d --- /dev/null +++ b/data/scripts/init.lua @@ -0,0 +1,56 @@ +-- Registry of hand-ported map scripts. Map-specific behavior lives HERE, +-- never in engine classes (Critical Engineering Rule #9). +-- +-- Each module returns { talk = { [TEXT_CONST] = script }, onEnter = fn, +-- onBoulderMoved = fn } where a script is a list of { "command", args... } +-- rows executed by src/script/ScriptRunner.lua. Every hand-ported script +-- cites the pokered source it was ported from. + +local registry = { + PALLET_TOWN = require("data.scripts.pallet_town"), + OAKS_LAB = require("data.scripts.oaks_lab"), + REDS_HOUSE_1F = require("data.scripts.reds_house"), + CELADON_MANSION_ROOF_HOUSE = require("data.scripts.celadon_eevee"), +} + +-- story-critical scripts, one table per map. Later files MERGE into +-- earlier ones: talk tables merge per TEXT constant, other hooks +-- (onEnter, onVictory, ...) are replaced, so different files can each +-- add NPCs to the same map. +for _, file in ipairs({ "data.scripts.story", "data.scripts.story2", + "data.scripts.story3", "data.scripts.story4", + "data.scripts.story5", "data.scripts.story6", + "data.scripts.story7", "data.scripts.flavor_all", + "data.scripts.safari", "data.scripts.seafoam", + "data.scripts.gyms" }) do + for mapId, mod in pairs(require(file)) do + local existing = registry[mapId] + if not existing then + registry[mapId] = mod + else + for k, v in pairs(mod) do + if k == "talk" and existing.talk then + for textConst, script in pairs(v) do + existing.talk[textConst] = script + end + else + existing[k] = v + end + end + end + end +end + +local M = {} + +function M.get(mapId) + return registry[mapId] +end + +-- script to run when the player talks to an object with this TEXT_ constant +function M.talkScript(mapId, textConst) + local mod = registry[mapId] + return mod and mod.talk and mod.talk[textConst] or nil +end + +return M diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua new file mode 100644 index 00000000..8144515a --- /dev/null +++ b/data/scripts/oaks_lab.lua @@ -0,0 +1,185 @@ +-- Hand-ported from pret/pokered scripts/OaksLab.asm. All text is real +-- extracted text. +-- +-- * Starter poke balls (objects 2-4): ask, give the real species, flag, +-- then the rival's counter-pick: he steps to the countering ball, +-- takes it ("I'll take this one, then!") and both balls disappear. +-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText / +-- OaksLabRivalTakePokeBallScript. +-- * Rival (object 1): before starter -> "gramps isn't around"; with +-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party +-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 = +-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm); +-- afterwards he gloats or sulks and marches out of the lab +-- (OaksLabRivalBattleEndScript). + +-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3); +-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column. +local function starterBall(askText, species, choseFlag, ownBall, + rivalBallX, rivalBall) + return { + { "check_flag", "EVENT_GOT_STARTER" }, -- 1 + { "jump_if_true", 19 }, -- 2 + -- no picking until Oak has walked you in (OaksLabScript gating) + { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 + { "jump_if_false", 19 }, -- 4 + { "ask", askText }, -- 5 + { "jump_if_false", 20 }, -- 6 + { "give_pokemon", species, 5 }, -- 7 + { "set_flag", "EVENT_GOT_STARTER" }, -- 8 + { "set_flag", choseFlag }, -- 9 + -- POKé BALLs are not handed out here in the original -- Oak gives + -- them later, at OaksLabOak1Text's .give_poke_balls beat once the + -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) + { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10 + { "hide_object", "OAKS_LAB", ownBall }, -- 11 + -- the rival walks to the countering ball (around the furniture) + { "move_npc_to", 1, rivalBallX, 4 }, -- 12 + { "face_object", 1, "up" }, -- 13 + { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14 + { "hide_object", "OAKS_LAB", rivalBall }, -- 15 + { "show_text", "_OaksLabRivalReceivedMonText", + { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" + or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" + or "BULBASAUR" } }, -- 16 + { "jump", 20 }, -- 17 + { "jump", 20 }, -- 18 (spacer) + { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19 + } +end + +return { + talk = { + -- Oak: accepts the parcel and hands over the Pokédex, then (once the + -- player has beaten the Route 22 rival) hands over the real POKé + -- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival + -- recall / dex-rating / "pokemon can fight" / "around the world" + -- branches -- see docs/known-differences.md) + TEXT_OAKSLAB_OAK1 = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 + { "jump_if_false", 12 }, -- 3 + { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 + { "jump_if_true", 12 }, -- 5 + { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 + { "take_item", "OAKS_PARCEL", 1 }, -- 7 + { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 + { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 + { "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 + { "jump", 30 }, -- 11 + { "check_flag", "EVENT_GOT_STARTER" }, -- 12 + { "jump_if_false", 27 }, -- 13 + { "check_item", "POKE_BALL" }, -- 14 + { "jump_if_true", 25 }, -- 15 + { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16 + { "jump_if_false", 29 }, -- 17 + { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18 + { "jump_if_true", 25 }, -- 19 + { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20 + { "give_item", "POKE_BALL", 5, false }, -- 21 + { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22 + { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23 + { "jump", 30 }, -- 24 + { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25 + { "jump", 30 }, -- 26 + { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27 + { "jump", 30 }, -- 28 + { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end) + }, + + TEXT_OAKSLAB_CHARMANDER_POKE_BALL = + starterBall("_OaksLabYouWantCharmanderText", "CHARMANDER", "EVENT_CHOSE_CHARMANDER", + "OAKSLAB_CHARMANDER_POKE_BALL", 7, "OAKSLAB_SQUIRTLE_POKE_BALL"), + TEXT_OAKSLAB_SQUIRTLE_POKE_BALL = + starterBall("_OaksLabYouWantSquirtleText", "SQUIRTLE", "EVENT_CHOSE_SQUIRTLE", + "OAKSLAB_SQUIRTLE_POKE_BALL", 8, "OAKSLAB_BULBASAUR_POKE_BALL"), + TEXT_OAKSLAB_BULBASAUR_POKE_BALL = + starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR", + "OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"), + + TEXT_OAKSLAB_RIVAL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_STARTER" }, -- 2 + { "jump_if_false", 20 }, -- 3 + { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4 + { "jump_if_true", 18 }, -- 5 + { "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6 + { "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7 + { "jump_if_false", 11 }, -- 8 + { "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander + { "jump", 16 }, -- 10 + { "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11 + { "jump_if_false", 15 }, -- 12 + { "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur + { "jump", 16 }, -- 14 + { "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle + { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16 + { "jump", 21 }, -- 17 + { "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18 + { "jump", 25 }, -- 19 + { "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20 + -- battle aftermath: on a win the rival sulks and marches out + -- (OaksLabRivalBattleEnd); on a loss the blackout already warped + -- us away, so the script just ends + { "jump_if_false", 25 }, -- 21 + { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22 + { "move_npc_to", 1, 4, 11 }, -- 23 + { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end) + }, + }, + + -- Oak stops you leaving without a starter; the rival stops you on + -- the way out for the first battle (scripts/OaksLab.asm + -- OaksLabScript8 / OaksLabRivalChallenge) + onStep = function(game, ow, x, y) + local flags = game.save.flags + -- Oak blocks the exit mats (4,11)/(5,11) until you take a starter + if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER + and y == 11 and (x == 4 or x == 5) then + ow.runner:run({ + { "show_text", "_OaksLabOakDontGoAwayYetText" }, + { "move_player", "up", 1 }, + }, {}) + return true + end + -- the challenge fires as soon as the player steps away from the + -- table (OaksLabRivalChallengesPlayerScript: wYCoord == 6) + if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB + and y >= 6 then + local rival = ow:npcByIndex(1) + if not rival then return false end + local rows = { + { "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1 + } + -- the rival routes to a free cell beside the player + local target + for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y }, + { x, y + 1 } }) do + if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then + target = c + break + end + end + if target then + table.insert(rows, { "move_npc_to", 1, target[1], target[2] }) + end + table.insert(rows, { "face_object", 1, + target and target[2] < y and "down" + or target and target[2] > y and "up" + or target and target[1] < x and "right" or "left" }) + local base = #rows + local party = flags.EVENT_CHOSE_BULBASAUR and 3 + or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1 + table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party }) + table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) + -- a loss blacks out to another map: end the script there + table.insert(rows, { "jump_if_false", base + 7 }) + table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) + table.insert(rows, { "move_npc_to", 1, 4, 11 }) + table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) + ow.runner:run(rows, { npc = rival }) + return true + end + return false + end, +} diff --git a/data/scripts/pallet_town.lua b/data/scripts/pallet_town.lua new file mode 100644 index 00000000..e4ac3352 --- /dev/null +++ b/data/scripts/pallet_town.lua @@ -0,0 +1,24 @@ +-- Hand-ported from pret/pokered scripts/PalletTown.asm. +-- +-- Only Oak needs a talk script: PalletTownOakText (scripts/ +-- PalletTown.asm:163) is a text_asm branch on wOakWalkedToPlayer showing +-- either _PalletTownOakHeyWaitDontGoOutText or _PalletTownOakItsUnsafeText; +-- we branch on the starter flag, which tracks it. The intro cutscene +-- itself (Oak stopping the player and walking them to the lab) is the +-- PALLET_TOWN onStep in data/scripts/story2.lua. +-- +-- The girl, fisher and the four signs resolve automatically through the +-- extracted text pointers (no script needed). + +return { + talk = { + TEXT_PALLETTOWN_OAK = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_STARTER" }, -- 2 + { "jump_if_true", 6 }, -- 3 + { "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4 + { "jump", 7 }, -- 5 + { "show_text", "_PalletTownOakItsUnsafeText" }, -- 6 + }, + }, +} diff --git a/data/scripts/reds_house.lua b/data/scripts/reds_house.lua new file mode 100644 index 00000000..f7b3dc04 --- /dev/null +++ b/data/scripts/reds_house.lua @@ -0,0 +1,15 @@ +-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm. +-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the +-- "you should rest" / "looking great" dialogue. The intro "wake up" +-- branch is tied to the unported intro cutscene, so the heal path is used. + +return { + talk = { + TEXT_REDSHOUSE1F_MOM = { + { "face_player" }, + { "show_text", "_RedsHouse1FMomYouShouldRestText" }, + { "heal_party" }, + { "show_text", "_RedsHouse1FMomLookingGreatText" }, + }, + }, +} diff --git a/data/scripts/safari.lua b/data/scripts/safari.lua new file mode 100644 index 00000000..c25cfdc8 --- /dev/null +++ b/data/scripts/safari.lua @@ -0,0 +1,116 @@ +-- The Safari Zone game entrance (scripts/SafariZoneGate.asm). +-- +-- Stepping on (3,2)/(4,2) next to the worker fires the join prompt +-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over +-- 30 SAFARI BALLs and starts the 502-step game +-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502, +-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back +-- so you can't slip past. Returning to the gate ends the game and the +-- worker takes the leftover balls back. +-- +-- Step/ball bookkeeping lives in src/world/OverworldController.lua +-- (safariStep/safariGameOver, from +-- engine/events/hidden_events/safari_game.asm); the in-battle +-- BALL/BAIT/ROCK/RUN game is src/battle/BattleState.lua makeSafari. + +local M = {} + +local FEE = 500 +local BALLS = 30 +local STEPS = 502 + +local function startGame(game, t, done) + game.save.money = game.save.money - FEE + game.save.safari = { balls = BALLS, steps = STEPS } + local TextBox = require("src.render.TextBox") + local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText + or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!") + :gsub("{PLAYER}", game.save.player.name) + local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText + or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!" + local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!" + game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done)) +end + +local function joinPrompt(game, ow, done) + done = done or function() end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local t = game.data.text + local back = function(text) + game.stack:push(TextBox.new(game, text, function() + ow:scriptMove(ow.player, "down", 1, done) + end)) + end + game.stack:push(TextBox.new(game, + t._SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText + or "For just ¥500 you\ncan join the hunt!\fWould you like to\njoin the hunt?", + function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then + back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText + or "OK! Please come\nagain!") + elseif game.save.money < FEE then + back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText + or "Oops! Not enough\nmoney!") + else + startGame(game, t, done) + end + end)) + end)) +end + +M.SAFARI_ZONE_GATE = { + talk = { + 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)) + end, + }, + + -- the join trigger cells in front of the worker + onStep = function(game, ow, x, y) + if y ~= 2 or (x ~= 3 and x ~= 4) then return false end + if game.save.safari then return false end -- paid, walking in + joinPrompt(game, ow, nil) + return true + end, + + -- arriving back from the zone (the north warps): the worker asks + -- "Leaving early?" -- yes ends the game and takes the leftover balls, + -- no walks you back into the zone + onEnter = function(game, ow) + if not game.save.safari or ow.player.cellY > 1 then return end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local t = game.data.text + game.stack:push(TextBox.new(game, + t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?", + function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then + -- back into the zone through the entrance warp + local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1] + ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up") + return + end + game.save.safari = nil + game.stack:push(TextBox.new(game, + (t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText + or "Please return any\nSAFARI BALLs you\nhave left.") + .. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText + or "Did you get a\ngood haul?\fCome again!"))) + end)) + end)) + end, +} + +return M diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua new file mode 100644 index 00000000..dcd1afe4 --- /dev/null +++ b/data/scripts/seafoam.lua @@ -0,0 +1,26 @@ +-- Seafoam Islands boulder/current puzzle wiring + the Vermilion Gym +-- locked door (map-enter hooks; the mechanics themselves live in +-- src/world/OverworldController.lua driven by field.seafoam and the +-- trash can data). +-- +-- Sources: scripts/SeafoamIslands1F.asm / B1F.asm / B3F.asm / B4F.asm +-- (currents, holes), scripts/VermilionGym.asm (VermilionGymSetDoorTile: +-- the door block at (2,2) is $24 until EVENT_2ND_LOCK_OPENED, then $5). +-- +-- The 1F->B1F->B2F->B3F->B4F boulder cascade is fully data-driven via +-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and +-- B3F's pluggedByHolesOn) plus the generic +-- OverworldState:boulderIntoHole in src/world/OverworldController.lua; +-- no per-map onEnter hook is needed here. + +local M = {} + +M.VERMILION_GYM = { + onEnter = function(game, ow) + if not game.save.flags.EVENT_2ND_LOCK_OPENED then + ow:replaceBlock(2, 2, 36) -- $24: the closed double door + end + end, +} + +return M diff --git a/data/scripts/story.lua b/data/scripts/story.lua new file mode 100644 index 00000000..1333b9e0 --- /dev/null +++ b/data/scripts/story.lua @@ -0,0 +1,609 @@ +-- Hand-ported story-critical scripts, one table per map, all using real +-- extracted text. Each cites its pokered source. Registered in +-- data/scripts/init.lua. + +local M = {} + +-- ------------------------------------------------------------------- +-- Oak's Parcel chain (scripts/ViridianMart.asm, OaksLab.asm, +-- ViridianCity.asm) +-- ------------------------------------------------------------------- + +M.VIRIDIAN_MART = { + talk = { + TEXT_VIRIDIANMART_CLERK = { + { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1 + { "jump_if_true", 11 }, -- 2 + { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 3 + { "jump_if_true", 13 }, -- 4 + { "check_flag", "EVENT_GOT_STARTER" }, -- 5 + { "jump_if_false", 11 }, -- 6 + { "show_text", "_ViridianMartClerkYouCameFromPalletTownText" }, -- 7 + -- the parcel-quest text's last page is "{PLAYER} got\nOAK's + -- PARCEL!" (scripts/ViridianMart.asm, sound_get_key_item) + { "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" }, -- 8 + { "set_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 9 + { "jump", 14 }, -- 10 + { "open_mart", "TEXT_VIRIDIANMART_CLERK" }, -- 11 + { "jump", 14 }, -- 12 + { "show_text", "_ViridianMartClerkSayHiToOakText" }, -- 13 + }, + }, +} + +M.VIRIDIAN_CITY = { + talk = { + -- the old man napping on the north path (scripts/ViridianCity.asm) + -- after his coffee, the old man offers the catch tutorial: not in a + -- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN) + TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = { + { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1 + { "jump_if_true", 5 }, -- 2 + { "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3 + { "jump", 14 }, -- 4 + { "face_player" }, -- 5 + { "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6 + { "jump_if_true", 12 }, -- 7 (in a hurry) + { "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8 + { "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9 + { "old_man_demo" }, -- 10 + { "jump", 13 }, -- 11 + { "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12 + { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13 + }, + }, + -- the sleeping old man lies across the Route 22 path (18,9): until + -- he moves you can't slip past on either side (scripts/ViridianCity + -- blocks the whole corridor, not just his tile) + onStep = function(game, ow, x, y) + local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY + and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false + if gone then return false end + -- crossing north of his row through the 3-wide gap (x 17-19, y<=8) + if y <= 8 and x >= 17 and x <= 19 then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._ViridianCityOldManSleepyPrivatePropertyText + or "You can't go\nthrough here!\fThis is private\nproperty!", + function() ow:scriptMove(ow.player, "down", 1) end)) + return true + end + return false + end, +} + +-- ------------------------------------------------------------------- +-- Bill's SS ticket (scripts/BillsHouse.asm; the cell-separation cutscene +-- is compressed into the dialogue) +-- ------------------------------------------------------------------- + +-- Daisy hands over the TOWN MAP once Oak's errand is under way +-- (scripts/BluesHouse.asm BluesHouseDaisySittingText) +M.BLUES_HOUSE = { + talk = { + TEXT_BLUESHOUSE_DAISY_SITTING = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2 + { "jump_if_true", 10 }, -- 3 + { "check_flag", "EVENT_GOT_STARTER" }, -- 4 + { "jump_if_false", 12 }, -- 5 + { "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6 + -- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the + -- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText) + { "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7 + { "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8 + { "jump", 13 }, -- 9 + { "show_text", "_BluesHouseDaisyUseMapText" }, -- 10 + { "jump", 13 }, -- 11 + { "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12 + }, + }, +} + +M.BILLS_HOUSE = { + talk = { + TEXT_BILLSHOUSE_BILL_POKEMON = { + { "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1 + { "jump_if_true", 11 }, -- 2 + { "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3 + { "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4 + -- the cell-separator PC throws its switch + -- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH) + { "play_sound", "Switch" }, -- 5 + { "show_text", "_BillsHouseBillThankYouText" }, -- 6 + -- pokered gives first (GiveItem fills wStringBuffer), then prints + -- the received text that reads it (scripts/BillsHouse.asm; the + -- item id is S_S_TICKET in generated items.lua -- keyItem, so the + -- sound_get_key_item jingle plays like BillsHouse.asm:196) + { "give_item", "S_S_TICKET", 1, false }, -- 7 + { "show_text", "_SSTicketReceivedText" }, -- 8 + { "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9 + { "jump", 12 }, -- 10 + { "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11 + }, + }, +} + +-- ------------------------------------------------------------------- +-- SS Anne (scripts/VermilionCity.asm, SSAnne2F.asm, +-- SSAnneCaptainsRoom.asm) +-- ------------------------------------------------------------------- + +M.VERMILION_CITY = { + -- VermilionCity_Script .setFirstLockTrashCanIndex (scripts/ + -- VermilionCity.asm): every load of this map re-rolls which Vermilion + -- Gym trash can hides the first-lock switch (Random & $0e: a random + -- EVEN can, 0-14). The roll is unconditional -- pokered does not + -- care whether the first lock is already open, because the index is + -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only + -- reachable through this map, so a fresh visit always re-rolls). + onEnter = function(game, ow) + local puz = game.save.trashPuzzle or {} + game.save.trashPuzzle = puz + puz.first = love.math.random(0, 7) * 2 + end, + talk = { + -- the sailor guarding the dock gangway + TEXT_VERMILIONCITY_SAILOR1 = { + { "face_player" }, -- 1 + { "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2 + { "check_item", "S_S_TICKET" }, -- 3 + { "jump_if_false", 8 }, -- 4 + { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5 + { "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6 + { "jump", 9 }, -- 7 + { "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8 + }, + }, +} + +M.SS_ANNE_2F = { + talk = { + TEXT_SSANNE2F_RIVAL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "show_text", "_SSAnne2FRivalText" }, -- 4 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 + { "jump_if_false", 10 }, -- 6 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 + { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 + { "jump", 10 }, -- 9 (already beaten: silent) + }, + }, +} + +M.SS_ANNE_CAPTAINS_ROOM = { + talk = { + TEXT_SSANNECAPTAINSROOM_CAPTAIN = { + { "check_flag", "EVENT_GOT_HM01" }, -- 1 + { "jump_if_true", 9 }, -- 2 + { "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3 + { "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4 + -- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem + -- fills wStringBuffer; the received text reads it) + { "give_item", "HM_CUT", 1, false }, -- 5 + { "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6 + { "set_flag", "EVENT_GOT_HM01" }, -- 7 + { "jump", 10 }, -- 8 + { "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9 + }, + }, +} + +-- ------------------------------------------------------------------- +-- Pokémon Tower / Poké Flute (scripts/PokemonTower7F.asm, +-- MrFujisHouse.asm; the teleport back to his house is a warp) +-- ------------------------------------------------------------------- + +M.POKEMON_TOWER_7F = { + talk = { + TEXT_POKEMONTOWER7F_MR_FUJI = { + { "face_player" }, -- 1 + { "show_text", "_PokemonTower7FMrFujiRescueText" }, -- 2 + { "set_flag", "EVENT_RESCUED_MR_FUJI" }, -- 3 + { "set_flag", "EVENT_RESCUED_MR_FUJI_2" }, -- 4 + -- pokered shows Fuji at home and swaps the Silph Co door + -- guard (ROCKET8 on the door tile -> ROCKET9 beside it) + { "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5 + { "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6 + { "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7 + { "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8 + }, + }, +} + +M.MR_FUJIS_HOUSE = { + -- repair saves from before the rescue toggled him visible + onEnter = function(game, ow) + if game.save.flags.EVENT_RESCUED_MR_FUJI then + local Commands = require("src.script.Commands") + Commands.show_object({ game = game, save = game.save, overworld = ow }, + "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI") + end + end, + talk = { + TEXT_MRFUJISHOUSE_MR_FUJI = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_POKE_FLUTE" }, -- 2 + { "jump_if_true", 12 }, -- 3 + { "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 4 + { "jump_if_false", 14 }, -- 5 + { "show_text", "_MrFujisHouseMrFujiIThinkThisMayHelpYourQuestText" }, -- 6 + -- give-then-print like scripts/MrFujisHouse.asm + { "give_item", "POKE_FLUTE", 1, false }, -- 7 + { "show_text", "_MrFujisHouseMrFujiReceivedPokeFluteText" }, -- 8 + { "set_flag", "EVENT_GOT_POKE_FLUTE" }, -- 9 + { "show_text", "_MrFujisHouseMrFujiPokeFluteExplanationText" }, -- 10 + { "jump", 15 }, -- 11 + { "show_text", "_MrFujisHouseMrFujiHasMyFluteHelpedYouText" }, -- 12 + { "jump", 15 }, -- 13 + { "show_text", "_MrFujisHouseMrFujiPokedexText" }, -- 14 + }, + }, +} + +-- ------------------------------------------------------------------- +-- Snorlax (scripts/Route12.asm, Route16.asm) +-- ------------------------------------------------------------------- + +-- each route has its own strings (text/Route12.asm, text/Route16.asm; +-- Route 16's sleeping line is the unnamed _Route16Text7). Talking to +-- Snorlax before it's beaten always just shows the sleeping line -- +-- Route12DefaultScript/Route16DefaultScript only special-case +-- EVENT_FIGHT_ROUTEnn_SNORLAX, which ItemUsePokeFlute sets when the +-- player USES the POKé FLUTE from the item-use menu while standing next +-- to Snorlax (see ItemEffects.lua's POKE_FLUTE branch); merely talking +-- to it with the flute in the bag does nothing. From the woke-up text +-- on, snorlaxWake below mirrors Route12DefaultScript's fight branch / +-- Route12SnorlaxPostBattleScript (scripts/Route12.asm, Route16.asm): +-- HideObject runs BEFORE the battle (so Snorlax is gone even after a +-- blackout), then the battle, then the calmed-down/returned line only +-- when it was NOT caught (`ld a, [wBattleResult] / cp $2` skips it), +-- and EVENT_BEAT_ROUTEnn_SNORLAX on any non-blackout result. +local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText) + return { + { "show_text", wokeUpText }, -- 1 + { "hide_object", mapId, objName }, -- 2 HideObject pre-battle + { "static_battle", "SNORLAX", 30, beatFlag }, -- 3 + { "check_battle_result", "win", "run" }, -- 4 not caught, not blackout + { "jump_if_false", 7 }, -- 5 end (skip calmed-down) + { "show_text", calmedText }, -- 6 + } +end + +-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via +-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax; +-- objName/beatFlag let ItemEffects find the NPC and check whether it's +-- already been beaten before allowing the wake. +M.ROUTE_12 = { + talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } }, + snorlaxWake = { + objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX", + script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX", + "_Route12SnorlaxWokeUpText", "_Route12SnorlaxCalmedDownText"), + }, +} +M.ROUTE_16 = { + talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } }, + snorlaxWake = { + objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX", + script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX", + "_Route16SnorlaxWokeUpText", "_Route16SnorlaxReturnedToMountainsText"), + }, +} + +-- ------------------------------------------------------------------- +-- Safari Zone HMs (scripts/SafariZoneSecretHouse.asm, WardensHouse.asm) +-- ------------------------------------------------------------------- + +M.SAFARI_ZONE_SECRET_HOUSE = { + talk = { + TEXT_SAFARIZONESECRETHOUSE_FISHING_GURU = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_HM03" }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "show_text", "_SafariZoneSecretHouseFishingGuruYouHaveWonText" }, -- 4 + -- give-then-print like scripts/SafariZoneSecretHouse.asm + { "give_item", "HM_SURF", 1, false }, -- 5 + { "show_text", "_SafariZoneSecretHouseFishingGuruReceivedHM03Text" }, -- 6 + { "set_flag", "EVENT_GOT_HM03" }, -- 7 + { "jump", 10 }, -- 8 + { "show_text", "_SafariZoneSecretHouseFishingGuruHM03ExplanationText" }, -- 9 + }, + }, +} + +M.WARDENS_HOUSE = { + talk = { + TEXT_WARDENSHOUSE_WARDEN = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_HM04" }, -- 2 + { "jump_if_true", 13 }, -- 3 + { "check_item", "GOLD_TEETH" }, -- 4 + { "jump_if_false", 15 }, -- 5 + { "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6 + { "take_item", "GOLD_TEETH", 1 }, -- 7 + { "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8 + { "show_text", "_WardensHouseWardenThanksText" }, -- 9 + -- give-then-print like scripts/WardensHouse.asm + { "give_item", "HM_STRENGTH", 1, false }, -- 10 + { "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11 + { "set_flag", "EVENT_GOT_HM04" }, -- 12 + { "jump", 16 }, -- 13 (already got it) + { "jump", 16 }, -- 14 (unused) + { "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15 + }, + }, +} + +-- ------------------------------------------------------------------- +-- Silph Co. president (scripts/SilphCo11F.asm; Giovanni there is the +-- generic OPP_GIOVANNI#2 battle) +-- ------------------------------------------------------------------- + +M.SILPH_CO_11F = { + talk = { + TEXT_SILPHCO11F_SILPH_PRESIDENT = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2 + { "jump_if_false", 10 }, -- 3 + { "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4 + { "jump_if_true", 10 }, -- 5 + { "show_text", "_SilphCo11FSilphPresidentText" }, -- 6 + -- give-then-print like scripts/SilphCo11F.asm + { "give_item", "MASTER_BALL", 1, false }, -- 7 + { "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8 + { "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9 + { "jump", 11 }, -- 10 + }, + }, +} + +-- ------------------------------------------------------------------- +-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): +-- a boulder resting on a switch removes a barrier block; the 3F hole +-- drops a boulder down to the 2F switch. +-- ------------------------------------------------------------------- + +local function boulderAt(ow, x, y) + local npc = ow:npcAtCell(x, y) + return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil +end + +M.VICTORY_ROAD_1F = { + onEnter = function(game, ow) + if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then + ow:replaceBlock(4, 6, 0x1D) + end + end, + onBoulderMoved = function(game, ow, npc) + if npc.cellX == 17 and npc.cellY == 13 + and not game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then + game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = true + ow:replaceBlock(4, 6, 0x1D) + end + end, +} + +M.VICTORY_ROAD_2F = { + onEnter = function(game, ow) + if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then + ow:replaceBlock(3, 4, 0x15) + end + if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then + ow:replaceBlock(11, 7, 0x1D) + end + end, + onBoulderMoved = function(game, ow, npc) + if npc.cellX == 1 and npc.cellY == 16 + and not game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then + game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = true + ow:replaceBlock(3, 4, 0x15) + end + if npc.cellX == 9 and npc.cellY == 16 + and not game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then + game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = true + ow:replaceBlock(11, 7, 0x1D) + end + end, +} + +M.VICTORY_ROAD_3F = { + onEnter = function(game, ow) + if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then + ow:replaceBlock(3, 5, 0x1D) + end + end, + onBoulderMoved = function(game, ow, npc) + if npc.cellX == 3 and npc.cellY == 5 + and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then + game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true + ow:replaceBlock(3, 5, 0x1D) + end + -- the hole at (23,15): the boulder drops to 2F next to switch 2 + if npc.cellX == 23 and npc.cellY == 15 then + local Commands = require("src.script.Commands") + local ctx = { save = game.save, overworld = ow, game = game } + Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name) + Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") + end + end, +} + +-- ------------------------------------------------------------------- +-- Champion (scripts/ChampionsRoom.asm): rival with the Rival3 party for +-- your starter, then Oak's congratulations (Hall of Fame-lite). +-- ------------------------------------------------------------------- + +-- The battle gate is the run-scoped EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN, +-- cleared by the Indigo lobby's Elite Four reset (scripts/ +-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches, +-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set +-- forever (postgame gates like the Cerulean cave guard read it). +-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript -> +-- OakArrivesScript -> OakCongratulatesPlayerScript -> +-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript, +-- then the simulated walk up-and-left into the HALL_OF_FAME warp +-- (PlayerFollowsOakScript / WalkToHallOfFame_RLEMovement). The induction +-- itself is NOT run here: it belongs to the HALL_OF_FAME room script +-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room +-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record. +M.CHAMPIONS_ROOM = { + talk = { + TEXT_CHAMPIONSROOM_RIVAL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 + { "jump_if_true", 24 }, -- 3 + { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 + { "rival_battle", "OPP_RIVAL3", 1 }, -- 5 + { "jump_if_false", 24 }, -- 6 + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 + -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, + -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = + -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText + -- is the port's generic " defeated BLUE!" engine line instead). + { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 + -- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in + { "show_text", "_ChampionsRoomOakText" }, -- 10 + { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11 + { "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2) + -- OakCongratulatesPlayerScript: rival faces left, Oak faces down + { "face_object", 1, "left" }, -- 13 + { "face_object", 2, "down" }, -- 14 + { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15 + -- OakDisappointedWithRivalScript: Oak turns to the rival (right) + { "face_object", 2, "right" }, -- 16 + { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17 + -- OakComeWithMeScript: Oak faces down again, then exits up + { "face_object", 2, "down" }, -- 18 + { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19 + { "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0) + { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21 + -- hand the induction off to the HALL_OF_FAME room (consumed by its + -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) + { "set_field", "pendingHallOfFame", true }, -- 22 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23 + }, + }, +} + +-- ------------------------------------------------------------------- +-- Hall of Fame (scripts/HallOfFame.asm): the induction's entry point is +-- the ROOM, not the Champions Room script. HallOfFameDefaultScript walks +-- the player up 5 into Oak (HallOfFameEntryMovement), then +-- HallOfFameOakCongratulationsScript turns them face-to-face, shows +-- _HallOfFameOakText, hides Oak (predef HideObject) and runs +-- HallOfFameResetEventsAndSaveScript's predef HallOfFamePC (the induction + +-- credits, here Commands.record_hall_of_fame). +-- +-- onEnter fires DURING the Champions Room warp's setMap, while that warp +-- command's runner is still suspended-alive (yielded at the warp), so we +-- cannot start a runner here directly (ScriptRunner:run asserts the runner +-- is idle). Instead we QUEUE the cutscene: OverworldState:update drains +-- self.pendingScript once the warp's transition has finished and its runner +-- has gone dead, so the room cutscene begins a frame after the warp fully +-- completes. The one-shot save.pendingHallOfFame marker (set by the +-- Champions Room script) is consumed here so re-entering the room later +-- does not replay the induction. +M.HALL_OF_FAME = { + onEnter = function(game, ow) + -- self-heal saves poisoned by a prior version of this script, which + -- wrongly hid Oak here instead of the Cerulean Cave guard (see + -- CERULEAN_CITY onEnter, which handles the real HideObject target) + local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME + if toggles then toggles.HALLOFFAME_OAK = nil end + + if not game.save.pendingHallOfFame then return end + game.save.pendingHallOfFame = false + ow:queueScript({ + { "move_player", "up", 5 }, -- (4,7)->(4,2) beside Oak (5,2) + { "face_object", 1, "left" }, -- HALLOFFAME_OAK faces the player + { "face_player_dir", "right" }, -- player faces Oak (PLAYER_DIR_RIGHT) + { "show_text", "_HallOfFameOakText" }, -- TEXT_HALLOFFAME_OAK + -- HallOfFameOakCongratulationsScript hides TOGGLE_CERULEAN_CAVE_GUY + -- here, not Oak; that's handled separately by CERULEAN_CITY onEnter + -- gating on EVENT_BEAT_CHAMPION_RIVAL + { "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits + }) + end, +} + +-- ------------------------------------------------------------------- +-- Mid-game rival battles (scripts/CeruleanCity.asm, PokemonTower2F.asm); +-- Rival1 parties 7-9 are the Cerulean set, Rival2 parties 4-6 the Tower +-- set (data/trainers/parties.asm) +-- ------------------------------------------------------------------- + +M.CERULEAN_CITY = { + talk = { + TEXT_CERULEANCITY_RIVAL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 + { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 + { "jump_if_false", 10 }, -- 6 + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 + { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 + { "jump", 10 }, -- 9 + }, + }, +} + +M.POKEMON_TOWER_2F = { + talk = { + TEXT_POKEMONTOWER2F_RIVAL = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 + { "jump_if_true", 10 }, -- 3 + { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4 + { "rival_battle", "OPP_RIVAL2", 4 }, -- 5 + { "jump_if_false", 11 }, -- 6 + { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 + { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 + { "jump", 11 }, -- 9 + { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10 + }, + }, +} + +-- ------------------------------------------------------------------- +-- In-game trades (data/events/trades.asm; the NPC<->trade mapping is +-- from each map's DoInGameTradeDialogue call) +-- ------------------------------------------------------------------- + +M.ROUTE_2_TRADE_HOUSE = { + talk = { + -- The Scientist is plain flavor; the Game Boy Kid holds the trade + -- (data/scripts/flavor/route_2_trade_house.lua), per Route2TradeHouse.asm. + TEXT_ROUTE2TRADEHOUSE_SCIENTIST = { + { "face_player" }, + { "show_text", "_Route2TradeHouseScientistText" }, + }, + }, +} + +M.CERULEAN_TRADE_HOUSE = { + talk = { + -- The Granny is plain flavor (points you to her husband); the Gambler + -- holds the trade (data/scripts/flavor/cerulean_trade_house.lua), per + -- CeruleanTradeHouse.asm. + TEXT_CERULEANTRADEHOUSE_GRANNY = { + { "face_player" }, + { "show_text", "_CeruleanTradeHouseGrannyText" }, + }, + }, +} + +M.VERMILION_TRADE_HOUSE = { + talk = { + TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL = { + { "face_player" }, + { "trade", 5, "EVENT_TRADED_SPEAROW_FOR_FARFETCHD" }, -- DUX + }, + }, +} + +return M diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua new file mode 100644 index 00000000..150d4f04 --- /dev/null +++ b/data/scripts/story2.lua @@ -0,0 +1,720 @@ +-- More hand-ported events: the Pallet Town intro, the thirsty Saffron +-- gate guards, the Bike Voucher chain, fossils and the day-care. +-- Registered via data/scripts/init.lua; each cites its pokered source. + +local M = {} + +-- ------------------------------------------------------------------- +-- Pallet Town intro (scripts/PalletTown.asm PalletTownOakHeyWaitScript): +-- stepping toward the grass without a starter makes Oak stop you and +-- take you to his lab (the walk cutscene is compressed into the warp). +-- ------------------------------------------------------------------- + +-- Pure escort data, exposed on the map entry as `escort` for +-- tests/parity_intro.lua. +local escort = {} + +-- FindPathToPlayer (engine/overworld/pathfinding.asm): each step +-- reduces whichever axis has more distance left; ties step X first +-- (`ld a, e / cp d / jr c, .yDistanceGreater`, e = X left, d = Y left). +function escort.findPath(fromX, fromY, toX, toY) + local xdist, ydist = math.abs(toX - fromX), math.abs(toY - fromY) + local xdir = toX > fromX and "right" or "left" + local ydir = toY > fromY and "down" or "up" + local xprog, yprog = 0, 0 + local path = {} + while xprog < xdist or yprog < ydist do + if xdist - xprog >= ydist - yprog and xprog < xdist then + xprog = xprog + 1 + path[#path + 1] = xdir + else + yprog = yprog + 1 + path[#path + 1] = ydir + end + end + return path +end + +-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot +-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance +-- is pre-decremented before predef FindPathToPlayer). +function escort.oakApproach(playerX) + return escort.findPath(8, 5, playerX, 2) +end + +-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm): +-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the +-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in- +-- place beat on the mat; here Oak just stands his final beat.) +escort.oakSteps = { + "down", "down", "down", "down", "down", + "left", + "down", "down", "down", "down", "down", + "right", "right", "right", + "up", +} + +-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT, +-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd +-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's +-- exact path one step behind. The 17th press (the second UP) is eaten +-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so +-- only 16 real steps happen; the walk ends on the door at (12,11). +escort.playerSteps = { "down" } +for _, d in ipairs(escort.oakSteps) do + escort.playerSteps[#escort.playerSteps + 1] = d +end + +M.PALLET_TOWN = { + talk = require("data.scripts.pallet_town").talk, + escort = escort, + -- Oak stops you at the north row (PalletTownDefaultScript's + -- `wYCoord == 1` check), walks up from (8,5), and leads you to his + -- lab with the player one step behind (scripts/PalletTown.asm + + -- PalletMovementScriptPointerTable in + -- engine/overworld/auto_movement.asm), then the lab walk-in and the + -- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript .. + -- OaksLabOakChooseMonSpeechScript). + onStep = function(game, ow, x, y) + if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB + or game.save.flags.EVENT_GOT_STARTER then + return false + end + local TextBox = require("src.render.TextBox") + local Commands = require("src.script.Commands") + local Music = require("src.core.Music") + local t = game.data.text + local ctx = { save = game.save, game = game, overworld = ow } + + -- PalletTownDefaultScript: stop the player, turn them around + -- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very + -- next frame, before the text box opens) and strike up the "oak + -- appears" theme (MUSIC_MEET_PROF_OAK) + ow.player.facing = "down" + Music.play(game.data, "Music_MeetProfOak") + + -- DelayFrames-style hold: the world pauses (input stays locked) + -- for `frames` frames, then cb runs. Reuses the emote pause slot; + -- with an `npc` the "!" bubble draws above it (EmotionBubble). + local function hold(frames, npc, cb) + ow.emote = { frames = frames, npc = npc, onDone = cb } + end + + -- chain single-tile scriptMoves through a direction list + local function walkList(entity, steps, done) + local i = 0 + local function nextStep() + i = i + 1 + if not entity or not steps[i] then + if done then done() end + return + end + ow:scriptMove(entity, steps[i], 1, nextStep) + end + nextStep() + end + + -- ---- Oak's Lab side (scripts/OaksLab.asm) ---------------------- + + -- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon / + -- what-about-me / be-patient exchange, Delay3 between boxes + local function chooseMonSpeech() + local function say(key, fb, next) + game.stack:push(TextBox.new(game, t[key] or fb, next)) + end + say("_OaksLabRivalFedUpWithWaitingText", + "{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function() + hold(3, nil, function() + say("_OaksLabOakChooseMonText", + "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function() + hold(3, nil, function() + say("_OaksLabRivalWhatAboutMeText", + "{RIVAL}: Hey!\nGramps! What\nabout me?", function() + hold(3, nil, function() + say("_OaksLabOakBePatientText", + "OAK: Be patient!\n{RIVAL}, you can\nhave one too!", function() + game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true + end) + end) + end) + end) + end) + end) + end) + end + + -- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3 + -- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement), + -- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player + -- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival + -- and Oak turn with them (OaksLabPlayerEntersLabScript / + -- OaksLabFollowedOakScript) + local function labWalkIn() + local oak2 = ow:npcByIndex(8) + local function swapOaks() + Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") + Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1") + hold(3, nil, function() -- Delay3 + Commands.face_object(ctx, 1, "down") -- rival watches you pass + ow:scriptMove(ow.player, "up", 8, function() + -- OaksLabFollowedOakScript: flags only after the walk-in, so a + -- stray step on the door mat can't fire the "don't go away" + -- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort + -- still re-arms on F1 mid-escort -- these flags stay clear + -- until the lab walk finishes. + game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true + Commands.face_object(ctx, 1, "up") + -- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme + -- only starts once the walk-in is done + Music.playMap(game.data, "OAKS_LAB") + chooseMonSpeech() + end) + end) + end + if oak2 then + ow:scriptMove(oak2, "up", 3, swapOaks) + else + swapOaks() + end + end + + -- PalletMovementScript_Done + the door warp: Oak is hidden as the + -- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB + -- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE) + local function enterLab() + Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") + Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") + ow.doorWarp = true + ow:startWarpTo("OAKS_LAB", 5, 11, "up", labWalkIn, + { keepMusic = true }) + end + + -- PalletMovementScript_WalkToLab: Oak's NPC movement and the + -- player's simulated joypad run simultaneously, in lockstep; the + -- player retraces Oak's path one step behind and follows him into + -- the doorway on the final beat + local function walkToLab(oak) + local i = 0 + local function tick() + i = i + 1 + local playerStep = escort.playerSteps[i] + if not playerStep then + enterLab() + return + end + if oak and escort.oakSteps[i] then + ow:scriptMove(oak, escort.oakSteps[i], 1) + elseif oak then + -- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat: + -- Oak marches in place on the door mat while the player takes + -- the final step up behind him (movement.asm ChangeFacingDirection) + ow:marchInPlace(oak) + end + ow:scriptMove(ow.player, playerStep, 1, tick) + end + tick() + end + + -- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right + -- tile (x == 11) Oak sidesteps left first, then the player follows + -- left (wNumStepsToTake = wXCoord - 10), and only then both walk + local function escortToLab(oak) + local numSteps = x - 10 + if oak and numSteps > 0 then + ow:scriptMove(oak, "left", numSteps, function() + ow:scriptMove(ow.player, "left", numSteps, function() + walkToLab(oak) + end) + end) + else + walkToLab(oak) + end + end + + -- ---- Pallet Town side (scripts/PalletTown.asm) ----------------- + + -- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up + -- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the + -- player; the "It's unsafe!" text follows and the escort begins + local function oakAppearsAndWalks() + Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") + local oak = ow:npcByIndex(1) + if oak then oak.facing = "up" end + hold(6, nil, function() + walkList(oak, escort.oakApproach(x), function() + -- PalletTownOakNotSafeComeWithMeScript: the second text waits + -- for a button, then the escort starts + game.stack:push(TextBox.new(game, + t._PalletTownOakItsUnsafeText + or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!", + function() escortToLab(oak) end)) + end) + end) + end + + -- The "Hey! Wait!" box ends without a button wait (auto), then the + -- "!" bubble shows over the player WHILE the box is still on screen + -- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet + -- cleared). onOverlap sets the bubble during the box's last frames; + -- the box pops after `overlap`, and the bubble's 60-frame hold then + -- runs to Oak's appearance (the bubble is static while the box is up, + -- since the overworld pauses under it, so 10 overlap + 50 = 60). + game.stack:push(TextBox.new(game, + t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!", + nil, { auto = { delay = 10, overlap = 10, onOverlap = function() + ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks } + end } })) + return true + end, +} + +-- ------------------------------------------------------------------- +-- Saffron gate guards (scripts/Route5Gate.asm etc.): crossing the gate +-- without having given them a drink gets you turned back; a drink from +-- the bag (bought at Celadon's vending machines... or any mart that +-- stocks them) opens all four gates. +-- ------------------------------------------------------------------- + +local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" } + +local function saffronGate(guardText, triggers, horizontal) + return { + talk = { + [guardText] = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local t = game.data.text + if game.save.flags.EVENT_GAVE_GUARDS_DRINK then + game.stack:push(TextBox.new(game, + t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done)) + return + end + for _, drink in ipairs(DRINKS) do + if (game.save.inventory[drink] or 0) > 0 then + game.save.inventory[drink] = game.save.inventory[drink] - 1 + if game.save.inventory[drink] == 0 then + game.save.inventory[drink] = nil + end + game.save.flags.EVENT_GAVE_GUARDS_DRINK = true + game.stack:push(TextBox.new(game, + (t._SaffronGateGuardYouCanGoOnThroughText or + "Thanks! You can\ngo on through!"), done)) + return + end + end + game.stack:push(TextBox.new(game, + t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done)) + end, + }, + -- the gate's trigger cells (each gate's PlayerInCoordsArray): + -- without the drink flag you get walked back the way you came + onStep = function(game, ow, x, y) + local hit = false + for _, c in ipairs(triggers) do + if x == c[1] and y == c[2] then hit = true break end + end + if not hit then return false end + if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end + local TextBox = require("src.render.TextBox") + local t = game.data.text + 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._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!", + function() + ow:scriptMove(ow.player, back, 1) + end)) + return true + end, + } +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) + +-- ------------------------------------------------------------------- +-- Bike Voucher chain (scripts/PokemonFanClub.asm, BikeShop.asm) +-- ------------------------------------------------------------------- + +M.POKEMON_FAN_CLUB = { + talk = { + TEXT_POKEMONFANCLUB_CHAIRMAN = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4 + { "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5 + -- give-then-print like scripts/PokemonFanClub.asm (GiveItem + -- fills wStringBuffer; the received text reads it) + { "give_item", "BIKE_VOUCHER", 1, false }, -- 6 + { "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7 + { "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8 + { "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9 + }, + }, +} + +M.BIKE_SHOP = { + talk = { + TEXT_BIKESHOP_CLERK = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + if (game.save.inventory.BICYCLE or 0) > 0 then + game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done)) + elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then + game.save.inventory.BIKE_VOUCHER = nil + game.save.inventory.BICYCLE = 1 + game.stack:push(TextBox.new(game, + ("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!") + :format(game.save.player.name), done)) + else + game.stack:push(TextBox.new(game, + "A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done)) + end + end, + }, +} + +-- ------------------------------------------------------------------- +-- Fossils (scripts/MtMoonB2F.asm, Museum1F.asm, +-- CinnabarLabFossilRoom.asm): pick one Mt Moon fossil, revive them at +-- the Cinnabar lab (the wait is skipped). +-- ------------------------------------------------------------------- + +local function mtMoonFossil(itemId, otherName) + return function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + if game.save.flags.EVENT_GOT_A_FOSSIL then + game.stack:push(TextBox.new(game, "You already took\na fossil.", done)) + return + end + game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then done() return end + game.save.inventory[itemId] = 1 + game.save.flags.EVENT_GOT_A_FOSSIL = true + local Commands = require("src.script.Commands") + local ctx = { save = game.save, overworld = ow, game = game } + Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name) + Commands.hide_object(ctx, "MT_MOON_B2F", otherName) + local name = game.data.items[itemId].name + game.stack:push(TextBox.new(game, + ("%s got the\n%s!"):format(game.save.player.name, name), done)) + end)) + end)) + end +end + +M.MT_MOON_B2F = { + talk = { + TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"), + TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"), + }, +} + +-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): +-- Y50, once; declining at the rope walks you back out. +local function museumClerk(game, ow, done, onDecline) + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then + game.stack:push(TextBox.new(game, + "Take your time,\nand enjoy it all!", done)) + return + end + game.stack:push(TextBox.new(game, + "It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if yes and game.save.money >= 50 then + game.save.money = game.save.money - 50 + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true + game.stack:push(TextBox.new(game, + "Right, ¥50!\nThank you!", done)) + elseif yes then + game.stack:push(TextBox.new(game, + "You don't have\nenough money.", onDecline or done)) + else + game.stack:push(TextBox.new(game, + "Come again!", onDecline or done)) + end + end)) + end)) +end + +M.MUSEUM_1F = { + -- crossing the rope at (9,4)/(10,4) without a ticket calls the clerk + -- over (Museum1FDefaultScript's coordinate check) + onStep = function(game, ow, x, y) + if y == 4 and (x == 9 or x == 10) + and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then + museumClerk(game, ow, nil, function() + ow:scriptMove(ow.player, "right", 1) + end) + return true + end + return false + end, + talk = { + TEXT_MUSEUM1F_SCIENTIST1 = function(game, ow, npc, done) + museumClerk(game, ow, done) + end, + -- The OLD AMBER display object is plain flavor; the scientist + -- (TEXT_MUSEUM1F_SCIENTIST2, data/scripts/flavor/museum_1f.lua) is who + -- hands it over and hides this object, per scripts/Museum1F.asm. + TEXT_MUSEUM1F_OLD_AMBER = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._Museum1FOldAmberText or "The OLD AMBER.", done)) + end, + }, +} + +local FOSSIL_MONS = { + HELIX_FOSSIL = "OMANYTE", DOME_FOSSIL = "KABUTO", OLD_AMBER = "AERODACTYL", +} + +-- Deterministic scan order mirrors FossilsList (scripts/ +-- CinnabarLabFossilRoom.asm lines 43-47): DOME_FOSSIL, HELIX_FOSSIL, +-- OLD_AMBER (the old pairs()-order loop this replaced was undefined). +local FOSSIL_ORDER = { "DOME_FOSSIL", "HELIX_FOSSIL", "OLD_AMBER" } + +-- fills the {PLAYER}/{RAM:...} placeholders in the extracted text +-- verbatim (text/CinnabarLabFossilRoom.asm); TextBox itself only knows +-- how to substitute {RAM:wStringBuffer}, so this has to happen first. +-- RAM placeholders resolve by buffer name from subs (SeesFossilText +-- reads both wNameBuffer and wStringBuffer), falling back to subs.ram. +local function fillFossilText(s, subs) + s = s:gsub("{PLAYER}", subs.player or "") + s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or subs.ram or "" end) + return s +end + +M.CINNABAR_LAB_FOSSIL_ROOM = { + talk = { + -- Fossil revival quest (scripts/CinnabarLabFossilRoom.asm + -- CinnabarLabFossilRoomScientist1Text lines 49-99, deposit flow in + -- engine/events/cinnabar_lab.asm GiveFossilToCinnabarLab): deposit + -- a fossil -> pending for the rest of this visit -> ready once the + -- player leaves and re-enters the CINNABAR_ISLAND overworld map + -- (M.CINNABAR_ISLAND.onEnter in data/scripts/story5.lua clears + -- EVENT_LAB_STILL_REVIVING_FOSSIL there, mirroring CinnabarIsland. + -- asm line 6) -> hand over the mon and reset the whole quest so a + -- second fossil can be deposited later. + -- + -- The deposit itself follows GiveFossilToCinnabarLab: a bordered + -- top-left menu of every carried fossil (A/B watched; B backs out), + -- then SeesFossilText with a Yes/No confirm; both cancel paths + -- (B on the menu, NO on the confirm) share ComeAgainText. + TEXT_CINNABARLABFOSSILROOM_SCIENTIST1 = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local t = game.data.text + local f = game.save.flags + local subs = { player = game.save.player.name } + + if f.EVENT_GAVE_FOSSIL_TO_LAB then + if f.EVENT_LAB_STILL_REVIVING_FOSSIL then + -- .check_done_reviving -> still pending this visit + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1GoForAWalkText or + "I take a little\ntime!\fYou go for walk a\nlittle while!", done)) + return + end + -- STILL_REVIVING was cleared (CINNABAR_ISLAND was reloaded + -- since the deposit): .done_reviving, lines 72-83 + local species = game.save.labFossilMon + subs.ram = species and game.data.pokemon[species] + and game.data.pokemon[species].name or "" + f.EVENT_LAB_HANDING_OVER_FOSSIL_MON = true + game.stack:push(TextBox.new(game, + fillFossilText( + t._CinnabarLabFossilRoomScientist1FossilIsBackToLifeText or + "Where were you?\fYour fossil is\nback to life!\fIt was {RAM:x}\nlike I think!", + subs), + function() + if species then + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.give_pokemon(ctx, species, 30) + if not ctx.lastCheck then + -- GivePokemon failed (party+box full): pokered's + -- `jr nc, .done` leaves the quest pending so the + -- scientist re-offers the mon next visit instead of + -- destroying it. + game.stack:push(TextBox.new(game, + t._BoxIsFullText or "Box is full!", done)) + return + end + end + game.save.labFossilMon = nil + f.EVENT_GAVE_FOSSIL_TO_LAB = nil + f.EVENT_LAB_STILL_REVIVING_FOSSIL = nil + f.EVENT_LAB_HANDING_OVER_FOSSIL_MON = nil + done() + end)) + return + end + + -- No fossil deposited yet: the intro always plays first (.Text), + -- then either the fossil-select menu (GiveFossilToCinnabarLab) + -- or NoFossilsText. + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1Text or + "Hiya!\fI am important\ndoctor!\fI study here rare\nPOKéMON fossils!\fYou! Have you a\nfossil for me?", + function() + -- Lab4Script_GetFossilsInBag: every carried fossil, in + -- FossilsList order + local carried = {} + for _, fossil in ipairs(FOSSIL_ORDER) do + if (game.save.inventory[fossil] or 0) > 0 then + carried[#carried + 1] = fossil + end + end + if #carried == 0 then + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1NoFossilsText or + "No! Is too bad!", done)) + return + end + -- .cancelledGivingFossil: B on the menu and NO on the + -- confirm both land here + local function comeAgain() + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1ComeAgainText or + "Aiyah! You come\nagain!", done)) + end + local items = {} + for _, fossil in ipairs(carried) do + items[#items + 1] = { + label = game.data.items[fossil].name, + onSelect = function() + -- LoadFossilItemAndMonName: wNameBuffer = item name, + -- wStringBuffer = mon name; then .ScientistSeesFossilText + -- with YesNoChoice (cursor starts on YES) + local species = FOSSIL_MONS[fossil] + local def = game.data.pokemon[species] + subs.wNameBuffer = game.data.items[fossil].name + subs.wStringBuffer = def and def.name or species + game.stack:push(TextBox.new(game, + fillFossilText( + t._CinnabarLabFossilRoomScientist1SeesFossilText or + "Oh! That is\n{RAM:wNameBuffer}!\fIt is fossil of\n{RAM:wStringBuffer}, a\nPOKéMON that is\nalready extinct!\fMy Resurrection\nMachine will make\nthat POKéMON live\nagain!", + subs), + nil, { choice = function(yes) + if not yes then comeAgain() return end + -- YES: TakesFossilText, RemoveItemByID, GoForAWalk2, + -- SetEvents GAVE_FOSSIL_TO_LAB + STILL_REVIVING + require("src.inventory.Bag").remove(game.save, fossil, 1) + game.save.labFossilMon = species + f.EVENT_GAVE_FOSSIL_TO_LAB = true + f.EVENT_LAB_STILL_REVIVING_FOSSIL = true + game.stack:push(TextBox.new(game, + fillFossilText( + t._CinnabarLabFossilRoomScientist1TakesFossilText or + "So! You hurry and\ngive me that!\f{PLAYER} handed\nover {RAM:wNameBuffer}!", + subs), + function() + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1GoForAWalkText2 or + "I take a little\ntime!\fYou go for walk a\nlittle while!", done)) + end)) + end })) + end, + } + end + -- GiveFossilToCinnabarLab's menu: TextBoxBorder at 0,0 + -- (interior width $d, height 2 per fossil), A|B watched + local Menu = require("src.ui.Menu") + game.stack:push(Menu.new(game, items, + { tx = 0, ty = 0, tw = 15, onCancel = comeAgain })) + end)) + end, + -- the other scientist trades SAILOR: Ponyta -> Seel + -- (scripts/CinnabarLabFossilRoom.asm TRADE_FOR_SAILOR) + TEXT_CINNABARLABFOSSILROOM_SCIENTIST2 = { + { "face_player" }, + { "trade", 4, "EVENT_TRADED_PONYTA_FOR_SEEL" }, + }, + }, +} + +-- ------------------------------------------------------------------- +-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per +-- step; the fee is ¥100 plus ¥100 per level gained. +-- ------------------------------------------------------------------- + +M.DAYCARE = { + talk = { + TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local t = game.data.text + local dc = game.save.daycare + + if dc and dc.mon then + local Growth = require("src.pokemon.Growth") + local Stats = require("src.pokemon.Stats") + local mon = dc.mon + local def = game.data.pokemon[mon.species] + mon.exp = mon.exp + (dc.steps or 0) + local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp)) + local fee = 100 + (newLevel - mon.level) * 100 + local grew = newLevel > mon.level + mon.level = newLevel + mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) + mon.hp = mon.stats.hp + local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!") + or "Back already?" + game.stack:push(TextBox.new(game, + msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function() + game.stack:push(ChoiceBox.new(game, function(yes) + if yes and game.save.money >= fee then + game.save.money = game.save.money - fee + table.insert(game.save.party, mon) + game.save.daycare = nil + game.stack:push(TextBox.new(game, + t._DaycareGentlemanGotMonBackText or "Here you go!", done)) + else + game.stack:push(TextBox.new(game, + yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!") + or "Come again!", done)) + end + end)) + end)) + return + end + + if #game.save.party < 2 then + game.stack:push(TextBox.new(game, + "You only have one\nPOKéMON with you!", done)) + return + end + game.stack:push(TextBox.new(game, + t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then done() return end + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game, { + pickOnly = true, + onSwitch = function(mon) + for i, m in ipairs(game.save.party) do + if m == mon then table.remove(game.save.party, i) break end + end + game.save.daycare = { mon = mon, steps = 0 } + game.stack:push(TextBox.new(game, + t._DaycareGentlemanWillLookAfterMonText or + "Fine, I'll look\nafter it a while!", done)) + end, + })) + end)) + end)) + end, + }, +} + +return M diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua new file mode 100644 index 00000000..865a2bef --- /dev/null +++ b/data/scripts/story3.lua @@ -0,0 +1,430 @@ +-- Third batch of hand-ported events: fishing rod givers, the Marowak +-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure +-- and the Hall of Fame record. Each cites its pokered source. + +local M = {} + +-- ------------------------------------------------------------------- +-- Fishing rod givers (scripts/VermilionOldRodHouse.asm, +-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm) +-- ------------------------------------------------------------------- + +local function rodGiver(askText, receivedText, afterText, rodItem, flag) + return { + { "face_player" }, -- 1 + { "check_flag", flag }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "ask", askText }, -- 4 + { "jump_if_false", 10 }, -- 5 + -- give-then-print like the three rod-house scripts (GiveItem fills + -- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it) + { "give_item", rodItem, 1, false }, -- 6 + { "show_text", receivedText }, -- 7 + { "set_flag", flag }, -- 8 + { "jump", 10 }, -- 9 is below + } +end + +M.VERMILION_OLD_ROD_HOUSE = { + talk = { + TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU = rodGiver( + "_VermilionOldRodHouseFishingGuruDoYouLikeToFishText", + "_VermilionOldRodHouseFishingGuruTakeThisText", + "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText", + "OLD_ROD", "EVENT_GOT_OLD_ROD"), + }, +} +M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] = + { "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" } + +M.FUCHSIA_GOOD_ROD_HOUSE = { + talk = { + TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU = rodGiver( + "_FuchsiaGoodRodHouseFishingGuruText", + "_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText", + "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText", + "GOOD_ROD", "EVENT_GOT_GOOD_ROD"), + }, +} +M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] = + { "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" } + +M.ROUTE_12_SUPER_ROD_HOUSE = { + talk = { + TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU = rodGiver( + "_Route12SuperRodHouseFishingGuruDoYouLikeToFishText", + "_Route12SuperRodHouseFishingGuruReceivedSuperRodText", + "_Route12SuperRodHouseFishingGuruTryFishingText", + "SUPER_ROD", "EVENT_GOT_SUPER_ROD"), + }, +} +M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = + { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } + +-- ------------------------------------------------------------------- +-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at +-- (10,16) until identified with the Silph Scope and defeated. +-- ------------------------------------------------------------------- + +M.POKEMON_TOWER_6F = { + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end + if x ~= 10 or y ~= 16 then return false end + local TextBox = require("src.render.TextBox") + if not game.save.inventory.SILPH_SCOPE then + game.stack:push(TextBox.new(game, + "A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!", + function() + local back = ow.player.facing == "up" and "down" or "up" + ow:scriptMove(ow.player, back, 1) + end)) + return true + end + game.stack:push(TextBox.new(game, + "The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function() + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "MAROWAK", 30) + battle.onFinish = function(result) + if result == "win" then + game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true + game.stack:push(TextBox.new(game, + "The restless soul\ncalmed down and\ndeparted!")) + end + ow:afterBattle(result) + end + game.stack:push(battle) + end)) + return true + end, +} + +-- ------------------------------------------------------------------- +-- Elevators (scripts/SilphCoElevator.asm etc.): a floor menu built from +-- the maps whose warps lead to the elevator (fully data-driven). +-- +-- engine/events/elevator.asm DisplayElevatorFloorMenu: prints the floor +-- list (SPECIALLISTMENU -- a plain text list, constants/list_constants +-- .asm:7, not a graphical panel) built from each elevator's fixed +-- FLOOR_* table (e.g. scripts/SilphCoElevator.asm SilphCoElevatorFloors, +-- ascending FLOOR_1F..FLOOR_11F); wCurrentMenuItem is explicitly zeroed +-- so the cursor always rests on the topmost floor -- there is no +-- current-floor marker/wWhichFloor symbol anywhere in Gen1. Floor +-- labels are the short FLOOR_* item-name strings (data/items/names.asm: +-- 87-100 -- '1F'..'11F', 'B1F', 'B2F', 'B4F'), never the room/map name. +-- On B (`ret c`) nothing happens -- no warp, the player just stays put. +-- On A, the map script sees BIT_CUR_MAP_USED_ELEVATOR and runs +-- engine/overworld/elevator.asm ShakeElevator (src/world/ElevatorShake +-- .lua): the music stops, the BG scroll bounces -1/+1px for 100 +-- two-frame cycles with SFX_COLLISION each cycle, then +-- SFX_SAFARI_ZONE_PA plays out and the map theme returns, before the +-- player is delivered to the chosen floor. +-- keyGate: the Rocket Hideout panel refuses without the LIFT KEY +-- (scripts/RocketHideoutElevator.asm RocketHideoutElevatorText: +-- "It appears to need a key." and no floor menu) +-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s +-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another +-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in +-- After the ride the original does NOT jump-cut to the floor: choosing a +-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites +-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to +-- the chosen floor's exit warp, then the player walks out of the car onto +-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce +-- that here: rewrite the car's exit warps, then drive a short scripted +-- walk-out onto an exit tile and take the (now rewritten) warp, reusing +-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives). +local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" } +local WALK_ORDER = { "up", "down", "left", "right" } + +local function elevatorWalkOut(ow, floor) + local m, p = ow.map, ow.player + -- .UpdateWarp is run twice, so BOTH car warp entries get the same + -- (warp id, map id): point every exit warp at the picked floor's + -- elevator-door warp (the reciprocal warp found while building the + -- menu). The car map's def is shared generated data, but its own + -- warps are only ever read from inside the car, and this rewrite runs + -- on every ride before the walk-out fires, so it is self-correcting. + for _, w in ipairs(m.def.warps) do + w.destMap = floor.map + w.destWarp = floor.warpIdx + end + -- leave by the exit tile under the player (they warped in onto one), + -- else the nearest + local door + for _, w in ipairs(m.def.warps) do + if w.x == p.cellX and w.y == p.cellY then door = w break end + end + if not door then + local best + for _, w in ipairs(m.def.warps) do + local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY) + if not best or d < best then best, door = d, w end + end + end + -- the car interior sits on the door's walkable side; "out" is the + -- doorway direction (the map edge for Silph/Celadon, the top doorway + -- for the Rocket car). Fixed direction order keeps the step + -- deterministic when a door tile has several walkable neighbours + -- (Silph/Celadon step up into the car, the Rocket car steps down). + local into + for _, dir in ipairs(WALK_ORDER) do + local v = WALK_DIRVEC[dir] + local nx, ny = door.x + v[1], door.y + v[2] + if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end + end + into = into or "up" + local out = WALK_OPP[into] + local function leave() + ow:takeWarp(door) -- door SFX + warp to the rewritten floor target + end + -- step one tile into the car, then walk back through the doorway onto + -- the exit tile and take the warp: a visible walk-out on valid tiles + -- for either door orientation, instead of a jump cut + ow:scriptMove(p, into, 1, function() + ow:scriptMove(p, out, 1, leave) + end) +end + +local function elevator(elevatorMapId, keyGate, preFrames) + return { + onEnter = function(game, ow) + if keyGate and not game.save.inventory[keyGate.item] then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text[keyGate.text] or "It appears to\nneed a key.")) + return + end + local floors = {} + for mapId, def in pairs(game.data.maps) do + for i, w in ipairs(def.warps) do + if w.destMap == elevatorMapId then + -- short floor token pokered actually prints, e.g. + -- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F" + local token = mapId:match("_([^_]+)$") or mapId + -- 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 + end + end + end + -- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F), + -- not lexicographic -- otherwise 10F/11F sort before 2F..9F + table.sort(floors, function(a, b) + return (tonumber(a.token:match("%d+")) or 0) < + (tonumber(b.token:match("%d+")) or 0) + end) + local items = {} + for _, f in ipairs(floors) do + table.insert(items, { label = f.token, value = f }) + end + local ListMenu = require("src.ui.ListMenu") + game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, { + onChoose = function(item, list) + list:close() + -- the map-entry Transition (startWarpTo) calls onEnter from + -- its OWN midpoint callback, so this menu was pushed on top + -- of that still-active Transition; only the top state + -- updates, so it froze mid-fade instead of finishing. Left + -- alone it would resurface after the ride and play a stray + -- fade at the wrong time -- pop it now, before it can happen. + local Transition = require("src.render.Transition") + if getmetatable(game.stack:top()) == Transition then + game.stack:pop() + end + -- the whole ShakeElevator ride runs in place -- music stop, + -- 100 collision-thud scroll bounces, the PA chime -- and only + -- then does the player walk out of the car onto the chosen + -- floor (elevatorWalkOut rewrites the car's exit warps first) + local ElevatorShake = require("src.world.ElevatorShake") + game.stack:push(ElevatorShake.new(game, ow, { + preFrames = preFrames, + onDone = function() + elevatorWalkOut(ow, item.value) + end, + })) + end, + onCancel = function() + -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing + -- happens, the player just stays in the car + end, + })) + end, + } +end + +M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR") +M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9) +M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", + { item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" }) + +-- ------------------------------------------------------------------- +-- Game Corner coins, prizes, and the rocket-poster switch that reveals +-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm + +-- prize_mon_levels.asm) +-- ------------------------------------------------------------------- + +M.GAME_CORNER = { + -- the hideout stairs hide behind a wall block until the poster + -- switch is found (the block at (8,2) is $2a while + -- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after) + onEnter = function(game, ow) + local poster = game.data.field.gameCornerPoster + if not poster then return end + local block = game.save.flags[poster.event] and poster.openBlock + or poster.closedBlock + ow:replaceBlock(poster.x, poster.y, block) + -- pick this visit's lucky slot machine + -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm) + local seats = game.data.field.slotMachines.GAME_CORNER + ow.luckySlot = love.math.random(1, #seats) + end, + talk = { + -- the poster bg event: pressing A reveals the hidden switch + TEXT_GAMECORNER_POSTER = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local poster = game.data.field.gameCornerPoster + local t = game.data.text + local text = t._GameCornerPosterSwitchBehindPosterText + or "Hey!\fA switch behind\nthe poster!?\nLet's push it!" + if game.save.flags[poster.event] then + game.stack:push(TextBox.new(game, text, done)) + return + end + -- GameCornerPosterText: the SwitchBehindPosterText plays + -- SFX_SWITCH as it shows, then SFX_GO_INSIDE opens the stairs + require("src.core.Sound").play(game.data, "Switch") + game.stack:push(TextBox.new(game, text, function() + game.save.flags[poster.event] = true + require("src.core.Sound").play(game.data, "Go_Inside") + ow:replaceBlock(poster.x, poster.y, poster.openBlock) + done() + end)) + end, + TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local t = game.data.text + game.stack:push(TextBox.new(game, + (t._GameCornerClerk1DoYouNeedSomeGameCoinsText + or "Do you need some\ngame coins?\f¥1000 for 50."), function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then + game.stack:push(TextBox.new(game, + t._GameCornerClerk1PleaseComePlaySometimeText + or "No? Please come\nplay sometime!", done)) + return + end + -- scripts/GameCorner.asm GameCornerClerk1Text: coins need + -- the COIN CASE and room for at least 9 coins (Has9990Coins) + if not game.save.inventory.COIN_CASE then + game.stack:push(TextBox.new(game, + t._GameCornerClerk1DontHaveCoinCaseText + or "You don't have a\nCOIN CASE!", done)) + return + end + if (game.save.coins or 0) >= 9990 then + game.stack:push(TextBox.new(game, + t._GameCornerClerk1CoinCaseIsFullText + or "Oops! Your COIN\nCASE is full.", done)) + return + end + if game.save.money < 1000 then + game.stack:push(TextBox.new(game, + t._GameCornerClerk1CantAffordTheCoinsText + or "You can't afford\nthe coins!", done)) + return + end + game.save.money = game.save.money - 1000 + game.save.coins = math.min(9999, (game.save.coins or 0) + 50) + game.stack:push(TextBox.new(game, + (t._GameCornerClerk1ThanksHereAre50CoinsText + or "Thanks! Here are\nyour 50 coins!") + .. ("\fCOINS: %d"):format(game.save.coins), done)) + end)) + end)) + end, + }, +} + +-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm) +local PRIZES = { + { kind = "mon", species = "ABRA", level = 9, cost = 180 }, + { kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 }, + { kind = "mon", species = "NIDORINA", level = 17, cost = 1200 }, + { kind = "mon", species = "DRATINI", level = 18, cost = 2800 }, + { kind = "mon", species = "SCYTHER", level = 25, cost = 5500 }, + { kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, + { kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 }, + { kind = "item", item = "TM_HYPER_BEAM", cost = 5500 }, + { kind = "item", item = "TM_SUBSTITUTE", cost = 7700 }, +} + +local function prizeCounter(game, ow, npc, done) + local ListMenu = require("src.ui.ListMenu") + local Commands = require("src.script.Commands") + local items = {} + for _, p in ipairs(PRIZES) do + local label + if p.kind == "mon" then + label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) + else + label = game.data.items[p.item].name + end + table.insert(items, { label = label, right = tostring(p.cost), value = p }) + end + local list + list = ListMenu.new(game, "PRIZES (COINS)", items, { + footer = ("COINS %d"):format(game.save.coins or 0), + onChoose = function(item) + local p = item.value + if (game.save.coins or 0) < p.cost then + list.footer = "Not enough coins!" + return + end + game.save.coins = game.save.coins - p.cost + if p.kind == "mon" then + Commands.give_pokemon({ save = game.save, game = game }, + p.species, p.level) + else + game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1 + end + list.footer = ("Got it! COINS %d"):format(game.save.coins) + end, + onCancel = done, + }) + game.stack:push(list) +end + +M.GAME_CORNER_PRIZE_ROOM = { + talk = { -- the three prize counters are bg events + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter, + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter, + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter, + }, +} + +-- ------------------------------------------------------------------- +-- SS Anne departure (scripts/VermilionDock.asm): once HM01 is in hand +-- and the player steps off the dock, the ship sets sail. +-- ------------------------------------------------------------------- + +M.VERMILION_DOCK = { + onEnter = function(game, ow) + if game.save.flags.EVENT_SS_ANNE_LEFT then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._VermilionCitySailor1ShipSetSailText + or "The ship set sail.", function() + ow:startWarpTo("VERMILION_CITY", 19, 30, "up") + end)) + elseif game.save.flags.EVENT_GOT_HM01 then + game.save.flags.EVENT_SS_ANNE_LEFT = true + require("src.core.Sound").play(game.data, "SS_Anne_Horn") + end + end, +} + +return M diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua new file mode 100644 index 00000000..6417e2e8 --- /dev/null +++ b/data/scripts/story4.lua @@ -0,0 +1,636 @@ +-- Side events that were missing from the port (found by auditing +-- against pokered): Oak's aides, the Magikarp salesman, the Fighting +-- Dojo prize, the Silph LAPRAS, Copycat, Mr. Psychic, the Route 16 +-- FLY house, the Celadon rooftop vending machines + thirsty girl, and +-- the Name Rater. Each cites its pokered source. + +local M = {} + +local function text(game) return game.data.text end + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +local function ask(game, s, cb) + local ChoiceBox = require("src.ui.ChoiceBox") + push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) +end + +-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER}) +local function fill(s, subs) + s = s:gsub("{PLAYER}", subs.player or "") + s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end) + s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end) + return s +end + +-- ------------------------------------------------------------------- +-- Oak's aides (engine/events/oaks_aide.asm; Route2Gate / Route11Gate2F +-- / Route15Gate2F pass the requirement + reward) +-- ------------------------------------------------------------------- + +local function countOwned(save) + local n = 0 + for _ in pairs(save.pokedex and save.pokedex.owned or {}) do n = n + 1 end + return n +end + +local function oaksAide(threshold, itemId) + return function(game, ow, npc, done) + local t = text(game) + local flags = game.save.flags + local itemName = game.data.items[itemId].name + local flag = "EVENT_GOT_" .. itemId + if flags[flag] then + push(game, fill(t._OaksAideComeBackText or + "I already gave\nyou the {RAM:}!", + { num = threshold, ram = itemName }), done) + return + end + ask(game, fill(t._OaksAideHiText or + "Have you caught\n{NUM:} kinds of\nPOKéMON?", + { num = threshold, ram = itemName, player = game.save.player.name }), + function(yes) + if not yes then + push(game, fill(t._OaksAideComeBackText or "Come back later!", + { num = threshold, ram = itemName }), done) + return + end + local owned = countOwned(game.save) + if owned >= threshold then + if not require("src.inventory.Bag").add(game.save, itemId, 1) then + push(game, fill(t._OaksAideNoRoomText or + "No room for the\n{RAM:}!", { ram = itemName }), done) + return + end + flags[flag] = true + push(game, fill(t._OaksAideHereYouGoText or "Here you go!", + { num = owned, ram = itemName }), + function() + push(game, fill(t._OaksAideGotItemText or + "{PLAYER} got the\n{RAM:}!", + { ram = itemName, player = game.save.player.name }), done) + end) + else + push(game, fill(t._OaksAideUhOhText or + "You have only\ncaught {NUM:}!", + { num = owned, ram = itemName }), done) + end + end) + end +end + +M.ROUTE_2_GATE = { + talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH") }, +} +M.ROUTE_11_GATE_2F = { + talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER") }, +} +M.ROUTE_15_GATE_2F = { + talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") }, +} + +-- ------------------------------------------------------------------- +-- Magikarp salesman (scripts/MtMoonPokecenter.asm): ¥500 for a L5 +-- MAGIKARP, once +-- ------------------------------------------------------------------- + +M.MT_MOON_POKECENTER = { + talk = { + TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done) + local t = text(game) + if game.save.flags.EVENT_BOUGHT_MAGIKARP then + push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText + or "Well, I don't\ngive refunds!", done) + return + end + ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText + or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes) + if not yes then + push(game, t._MtMoonPokecenterMagikarpSalesmanNoText + or "No? I'm only\nselling today!", done) + return + end + if game.save.money < 500 then + push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText + or "You'll need more\nmoney than that!", done) + return + end + game.save.money = game.save.money - 500 + game.save.flags.EVENT_BOUGHT_MAGIKARP = true + local Commands = require("src.script.Commands") + Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, + "MAGIKARP", 5) + push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Fighting Dojo prize (scripts/FightingDojo.asm): after beating the +-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears) +-- ------------------------------------------------------------------- + +local function dojoBall(species, ownBall, otherBall, askKey) + return function(game, ow, npc, done) + local t = text(game) + local flags = game.save.flags + if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then + done() + return + end + if not flags.EVENT_BEAT_KARATE_MASTER then + push(game, "You'll have to\nbeat the master\nfirst!", done) + return + end + ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) + if not yes then done() return end + flags["EVENT_GOT_" .. species] = true + flags.EVENT_DEFEATED_FIGHTING_DOJO = true + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.give_pokemon(ctx, species, 30) + Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall) + Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall) + push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) + end) + end +end + +M.FIGHTING_DOJO = { + talk = { + TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL = + dojoBall("HITMONLEE", "FIGHTINGDOJO_HITMONLEE_POKE_BALL", + "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", + "_FightingDojoHitmonleePokeBallText"), + TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL = + dojoBall("HITMONCHAN", "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", + "FIGHTINGDOJO_HITMONLEE_POKE_BALL", + "_FightingDojoHitmonchanPokeBallText"), + }, +} + +-- ------------------------------------------------------------------- +-- Silph Co. 7F worker's LAPRAS (scripts/SilphCo7F.asm: L15, once, +-- after the rival fight area is reached; gift is unconditional here) +-- ------------------------------------------------------------------- + +M.SILPH_CO_7F = { + talk = { + TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done) + local t = text(game) + if game.save.flags.EVENT_GOT_LAPRAS then + push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText + or "How is LAPRAS\ndoing?", done) + return + end + push(game, t._SilphCo7FSilphWorkerM1ThankYouText + or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!", + function() + game.save.flags.EVENT_GOT_LAPRAS = true + local Commands = require("src.script.Commands") + Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, + "LAPRAS", 15) + push(game, ("%s got\nLAPRAS!"):format(game.save.player.name), + function() + push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText + or "It's a good\nswimmer!", done) + end) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Copycat (scripts/CopycatsHouse2F.asm): a POKE DOLL buys TM31 MIMIC +-- ------------------------------------------------------------------- + +M.COPYCATS_HOUSE_2F = { + talk = { + TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done) + local t = text(game) + if game.save.flags.EVENT_GOT_TM31 then + push(game, t._CopycatsHouse2FCopycatWhirlingText + or "Huh? Huh? Huh?", done) + return + end + if (game.save.inventory.POKE_DOLL or 0) > 0 then + ask(game, t._CopycatsHouse2FCopycatPokeDollText + or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes) + if not yes then done() return end + game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1 + if game.save.inventory.POKE_DOLL == 0 then + game.save.inventory.POKE_DOLL = nil + end + if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then + push(game, "You don't have\nroom for TM31!", done) + return + end + game.save.flags.EVENT_GOT_TM31 = true + push(game, ("%s got\nTM31!"):format(game.save.player.name), done) + end) + return + end + push(game, t._CopycatsHouse2FCopycatText + or "I like to mimic\npeople!", done) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Mr. Psychic (scripts/MrPsychicsHouse.asm): TM29 PSYCHIC, once +-- ------------------------------------------------------------------- + +M.MR_PSYCHICS_HOUSE = { + talk = { + TEXT_MRPSYCHICSHOUSE_MR_PSYCHIC = function(game, ow, npc, done) + local t = text(game) + if game.save.flags.EVENT_GOT_TM29 then + push(game, t._MrPsychicsHouseMrPsychicNoMoreText + or "...Hmm...", done) + return + end + push(game, t._MrPsychicsHouseMrPsychicText + or "...Wait!\nDon't say a word!\fYou came to get\nTM29!", function() + if not require("src.inventory.Bag").add(game.save, "TM_PSYCHIC_M", 1) then + push(game, "You don't have\nroom for TM29!", done) + return + end + game.save.flags.EVENT_GOT_TM29 = true + push(game, ("%s got\nTM29!"):format(game.save.player.name), done) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Route 16 hidden house (scripts/Route16FlyHouse.asm): HM02 FLY, once +-- ------------------------------------------------------------------- + +M.ROUTE_16_FLY_HOUSE = { + talk = { + TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done) + local t = text(game) + if game.save.flags.EVENT_GOT_HM02 then + push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText + or "HM02 is FLY!\fIt will whisk you\nback to any town!", done) + return + end + push(game, t._Route16FlyHouseBrunetteGirlText + or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function() + if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then + push(game, "You don't have\nroom for HM02!", done) + return + end + game.save.flags.EVENT_GOT_HM02 = true + push(game, ("%s got\nHM02!"):format(game.save.player.name), done) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Celadon rooftop (scripts/CeladonMartRoof.asm): vending machines sell +-- the three drinks; the thirsty girl trades drinks for TM13/48/49 +-- ------------------------------------------------------------------- + +local DRINK_PRICES = { + { id = "FRESH_WATER", price = 200 }, + { id = "SODA_POP", price = 300 }, + { id = "LEMONADE", price = 350 }, +} + +local function vendingMachine(game, ow, npc, done) + local ListMenu = require("src.ui.ListMenu") + local items = {} + for _, d in ipairs(DRINK_PRICES) do + table.insert(items, { + value = d, label = ("%s ¥%d"):format(game.data.items[d.id].name, d.price), + }) + end + game.stack:push(ListMenu.new(game, "VENDING MACHINE", items, { + onChoose = function(item, list) + local d = item.value + if game.save.money < d.price then + push(game, "Not enough\nmoney.") + return + end + if not require("src.inventory.Bag").add(game.save, d.id, 1) then + push(game, "You have no room\nfor it!") + return + end + game.save.money = game.save.money - d.price + push(game, ("%s\npopped out!"):format(game.data.items[d.id].name)) + end, + onCancel = done, + })) +end + +-- drink -> TM (CeladonMartRoof.asm .gaveFreshWater/.gaveSodaPop/ +-- .gaveLemonade branches of CeladonMartRoofScript_GiveDrinkToGirl) +local GIRL_TMS = { + { drink = "FRESH_WATER", tm = "TM_ICE_BEAM", flag = "EVENT_GOT_TM13", + yay = "_CeladonMartRoofLittleGirlYayFreshWaterText", + received = "_CeladonMartRoofLittleGirlReceivedTM13Text", + explain = "_CeladonMartRoofLittleGirlTM13ExplanationText" }, + { drink = "SODA_POP", tm = "TM_ROCK_SLIDE", flag = "EVENT_GOT_TM48", + yay = "_CeladonMartRoofLittleGirlYaySodaPopText", + received = "_CeladonMartRoofLittleGirlReceivedTM48Text", + explain = "_CeladonMartRoofLittleGirlTM48ExplanationText" }, + { drink = "LEMONADE", tm = "TM_TRI_ATTACK", flag = "EVENT_GOT_TM49", + yay = "_CeladonMartRoofLittleGirlYayLemonadeText", + received = "_CeladonMartRoofLittleGirlReceivedTM49Text", + explain = "_CeladonMartRoofLittleGirlTM49ExplanationText" }, +} + +M.CELADON_MART_ROOF = { + talk = { + TEXT_CELADONMARTROOF_VENDING_MACHINE1 = vendingMachine, + TEXT_CELADONMARTROOF_VENDING_MACHINE2 = vendingMachine, + TEXT_CELADONMARTROOF_VENDING_MACHINE3 = vendingMachine, + -- CeladonMartRoofLittleGirlText + Script_GiveDrinkToGirl: a menu + -- of the drinks in the bag; the chosen one earns its TM (once per + -- drink kind, EVENT_GOT_TM13/48/49) + TEXT_CELADONMARTROOF_LITTLE_GIRL = function(game, ow, npc, done) + local t = text(game) + local have = {} + for _, g in ipairs(GIRL_TMS) do + if (game.save.inventory[g.drink] or 0) > 0 then + table.insert(have, g) + end + end + if #have == 0 then + push(game, t._CeladonMartRoofLittleGirlImThirstyText + or "I'm thirsty!\nI want something\nto drink!", done) + return + end + ask(game, t._CeladonMartRoofLittleGirlGiveHerADrinkText + or "I'm thirsty!\nI want something\nto drink!\fGive her a drink?", + function(yes) + if not yes then done() return end + push(game, t._CeladonMartRoofLittleGirlGiveHerWhichDrinkText + or "Give her which\ndrink?", function() + local items = {} + for _, g in ipairs(have) do + table.insert(items, { + label = game.data.items[g.drink].name, value = g, + }) + end + local ListMenu = require("src.ui.ListMenu") + game.stack:push(ListMenu.new(game, "DRINKS", items, { + onChoose = function(item, list) + list:close() + local g = item.value + if game.save.flags[g.flag] then + push(game, t._CeladonMartRoofLittleGirlImNotThirstyText + or "No thank you!\nI'm not thirsty\nafter all!", done) + return + end + push(game, t[g.yay] + or "Yay!\fThank you!\fYou can have this\nfrom me!", function() + local Bag = require("src.inventory.Bag") + Bag.remove(game.save, g.drink, 1) + if not Bag.add(game.save, g.tm, 1) then + push(game, t._CeladonMartRoofLittleGirlNoRoomText + or "You don't have\nspace for this!", done) + return + end + game.save.flags[g.flag] = true + require("src.core.Sound").play(game.data, "Get_Item1") + local subs = { player = game.save.player.name, + ram = game.data.items[g.tm].name } + local explain = fill(t[g.explain] or "", subs) + :gsub("^\f", "") + push(game, fill(t[g.received] + or "{PLAYER} received\n{RAM:}!", subs), function() + if #explain > 0 then + push(game, explain, done) + else + done() + end + end) + end) + end, + onCancel = done, + })) + end) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Nugget Bridge recruiter (scripts/Route24.asm): the champ prize +-- NUGGET, the TEAM ROCKET pitch, then the battle +-- ------------------------------------------------------------------- + +M.ROUTE_24 = { + talk = { + TEXT_ROUTE24_COOLTRAINER_M1 = function(game, ow, npc, done) + local flags = game.save.flags + local function battleOrDone() + if ow:trainerDefeated(npc) then + push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done) + else + ow:engageTrainer(npc, done) + end + end + if not flags.EVENT_GOT_NUGGET then + push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f" + .. "You just earned a\nfabulous prize!", function() + flags.EVENT_GOT_NUGGET = true + require("src.inventory.Bag").add(game.save, "NUGGET", 1) + push(game, ("%s received\na NUGGET!"):format(game.save.player.name), + function() + ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?", + function() + push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n" + .. "you my power!", battleOrDone) + end) + end) + end) + return + end + battleOrDone() + end, + }, +} + +-- ------------------------------------------------------------------- +-- Cinnabar Lab trade room (scripts/CinnabarLabTradeRoom.asm): +-- Gramps trades DORIS (Raichu -> Electrode), the Beauty CRINKLES +-- (Venonat -> Tangela); indexes into data/events/trades.asm +-- ------------------------------------------------------------------- + +M.CINNABAR_LAB_TRADE_ROOM = { + talk = { + TEXT_CINNABARLABTRADEROOM_GRAMPS = { + { "face_player" }, + { "trade", 8, "EVENT_TRADED_RAICHU_FOR_ELECTRODE" }, -- DORIS + }, + TEXT_CINNABARLABTRADEROOM_BEAUTY = { + { "face_player" }, + { "trade", 9, "EVENT_TRADED_VENONAT_FOR_TANGELA" }, -- CRINKLES + }, + }, +} + +-- ------------------------------------------------------------------- +-- Name Rater (scripts/NameRatersHouse.asm): rename a party member +-- ------------------------------------------------------------------- + +M.NAME_RATERS_HOUSE = { + talk = { + TEXT_NAMERATERSHOUSE_NAME_RATER = function(game, ow, npc, done) + local t = text(game) + local function bye() + push(game, t._NameRatersHouseNameRaterComeAnyTimeYouLikeText + or "Fine! Come any\ntime you like!", done) + end + ask(game, t._NameRatersHouseNameRaterWantMeToRateText + or "Hello, hello!\nI am the official\nNAME RATER!\fWant me to rate\nthe nicknames of\nyour POKéMON?", + function(yes) + if not yes then bye() return end + push(game, t._NameRatersHouseNameRaterWhichPokemonText + or "Which POKéMON\nshould I look at?", function() + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game, { + pickOnly = true, + -- backing out of the party menu is .did_not_rename: + -- "Fine! Come any time you like!" (NameRatersHouse.asm) + onCancel = bye, + onSwitch = function(mon) + local def = game.data.pokemon[mon.species] + local curName = mon.nickname or def.name or mon.species + -- NameRatersHouseCheckMonOTScript: a mon whose OT name + -- or OT ID isn't the player's can't be renamed + local player = game.save.player + local foreign = mon.traded + or (mon.ot ~= nil and mon.ot ~= player.name) + or (mon.otId ~= nil and player.id ~= nil + and mon.otId ~= player.id) + if foreign then + push(game, fill(t._NameRatersHouseNameRaterATrulyImpeccableNameText + or "{RAM:}, is it?\nThat is a truly\nimpeccable name!\fTake good care of\n{RAM:}!", + { ram = curName }), done) + return + end + ask(game, fill(t._NameRatersHouseNameRaterGiveItANiceNameText + or "{RAM:}, is it?\nThat is a decent\nnickname!\fBut, would you\nlike me to give\nit a nicer name?\fHow about it?", + { ram = curName }), function(rename) + if not rename then bye() return end + push(game, t._NameRatersHouseNameRaterWhatShouldWeNameItText + or "Fine! What should\nwe name it?", function() + local NamingScreen = require("src.ui.NamingScreen") + game.stack:push(NamingScreen.new(game, { + title = (def.name or mon.species) .. "'s name?", + maxLen = 10, + default = mon.nickname, + onDone = function(name) + if name and #name > 0 and name ~= def.name then + mon.nickname = name + else + mon.nickname = nil + end + push(game, fill(t._NameRatersHouseNameRaterPokemonHasBeenRenamedText + or "OK! This POKéMON\nhas been renamed\n{RAM:}!\fThat's a better\nname than before!", + { ram = mon.nickname or def.name }), done) + end, + })) + end) + end) + end, + })) + end) + end) + end, + }, +} + +-- ------------------------------------------------------------------- +-- Saffron City occupation / liberation (scripts/SaffronCity.asm object +-- defaults + scripts/SilphCo11F.asm SilphCo11FTeamRocketLeavesScript + +-- scripts/PokemonTower7F.asm Fuji rescue). The street ROCKETs guard +-- the gym and Silph Co doors; rescuing Mr. Fuji swaps the Silph door +-- guard (ROCKET8 -> ROCKET9), and beating Silph Giovanni clears every +-- rocket and shows the liberated-city NPCs. Synced on map enter so it +-- also repairs saves made before this script existed. +-- ------------------------------------------------------------------- + +local SAFFRON_ROCKETS = { + "SAFFRONCITY_ROCKET1", "SAFFRONCITY_ROCKET2", "SAFFRONCITY_ROCKET3", + "SAFFRONCITY_ROCKET4", "SAFFRONCITY_ROCKET5", "SAFFRONCITY_ROCKET6", + "SAFFRONCITY_ROCKET7", "SAFFRONCITY_ROCKET8", "SAFFRONCITY_ROCKET9", +} +local SAFFRON_CIVILIANS = { + "SAFFRONCITY_SCIENTIST", "SAFFRONCITY_SILPH_WORKER_M", + "SAFFRONCITY_SILPH_WORKER_F", "SAFFRONCITY_GENTLEMAN", + "SAFFRONCITY_PIDGEOT", "SAFFRONCITY_ROCKER", +} + +M.SAFFRON_CITY = { + onEnter = function(game, ow) + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + for _, name in ipairs(SAFFRON_ROCKETS) do + Commands.hide_object(ctx, "SAFFRON_CITY", name) + end + for _, name in ipairs(SAFFRON_CIVILIANS) do + Commands.show_object(ctx, "SAFFRON_CITY", name) + end + elseif game.save.flags.EVENT_RESCUED_MR_FUJI then + Commands.hide_object(ctx, "SAFFRON_CITY", "SAFFRONCITY_ROCKET8") + Commands.show_object(ctx, "SAFFRON_CITY", "SAFFRONCITY_ROCKET9") + end + end, +} + +-- ------------------------------------------------------------------- +-- Elite Four room exit doors (scripts/LoreleisRoom.asm +-- LoreleiShowOrHideExitBlock and the Bruno/Agatha equivalents): the +-- block above the exit warp stays solid until the room's trainer is +-- beaten. LORELEIS_ROOM ships closed in the extracted .blk, so +-- without this the league was a dead end after Lorelei. +-- ------------------------------------------------------------------- + +local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag) + local seal = function(game, ow) + local open = game.save.flags[flag] + ow:replaceBlock(2, 0, open and openBlock or closedBlock) + -- the auto walk-in on first (south) entry (LoreleiScriptWalkIntoRoom) + if autoFlag and not game.save.flags[autoFlag] and ow.player.cellY >= 10 then + game.save.flags[autoFlag] = true + ow:scriptMove(ow.player, "up", 6) + end + end + -- onVictory re-runs the seal so the door opens right after the + -- battle, like pokered's post-battle map reload + return { + onEnter = seal, + onVictory = seal, + -- retreating toward the entrance gets "Don't run away!" and a + -- shove back up (the entrance coords rows in each room script) + onStep = function(game, ow, x, y) + if y < 10 or x < 4 or x > 5 then return false end + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text[dontRunText] or "Don't run away!", function() + ow:scriptMove(ow.player, "up", 1) + end)) + return true + end, + } +end + +M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05, + "_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM") +M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, + "_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM") +M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, + "_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") + +return M diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua new file mode 100644 index 00000000..6d7481d6 --- /dev/null +++ b/data/scripts/story5.lua @@ -0,0 +1,518 @@ +-- Gift NPCs and in-game trades. Each cites its pokered source. + +local M = {} + +local function text(game) return game.data.text end + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +-- fill the extracted text placeholders ({RAM:...}, {PLAYER}) +local function fill(s, subs) + s = s:gsub("{PLAYER}", subs.player or "") + s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end) + return s +end + +-- One-time item gift, following the original text_asm flow: +-- pre text (optional) -> GiveItem (bag-full refusal keeps the flag +-- unset, talk again after making room) -> received text -> optional +-- explanation; repeat visits get the already text. +local function gift(opts) + return function(game, ow, npc, done) + local t = text(game) + local itemName = game.data.items[opts.item].name + local subs = { ram = itemName, player = game.save.player.name } + local function say(label, fallback, cb) + push(game, fill(t[label] or fallback, subs), cb) + end + if game.save.flags[opts.flag] then + say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) + return + 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) + return + end + game.save.flags[opts.flag] = true + local idef = game.data.items[opts.item] + require("src.core.Sound").play(game.data, + (idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") + say(opts.received, "{PLAYER} received\n{RAM:}!", function() + if opts.explain then + say(opts.explain, "", done) + else + done() + end + end) + end + if opts.pre then say(opts.pre, "", give) else give() end + end +end + +-- Coin Case (scripts/CeladonDiner.asm, the busted gym guide) +M.CELADON_DINER = { + talk = { + TEXT_CELADONDINER_GYM_GUIDE = gift({ + flag = "EVENT_GOT_COIN_CASE", item = "COIN_CASE", + pre = "_CeladonDinerGymGuideImFlatOutBustedText", + received = "_CeladonDinerGymGuideReceivedCoinCaseText", + noRoom = "_CeladonDinerGymGuideCoinCaseNoRoomText", + already = "_CeladonDinerGymGuideWinItBackText", + }), + }, +} + +-- TM18 Counter (scripts/CeladonMart3F.asm, the TV-game-shop clerk) +M.CELADON_MART_3F = { + talk = { + TEXT_CELADONMART3F_CLERK = gift({ + flag = "EVENT_GOT_TM18", item = "TM_COUNTER", + pre = "_CeladonMart3FClerkTM18PreReceiveText", + received = "_CeladonMart3FClerkReceivedTM18Text", + explain = "_CeladonMart3FClerkTM18ExplanationText", + noRoom = "_CeladonMart3FClerkTM18NoRoomText", + }), + }, +} + +-- TM39 Swift (scripts/Route12Gate2F.asm) +M.ROUTE_12_GATE_2F = { + talk = { + TEXT_ROUTE12GATE2F_BRUNETTE_GIRL = gift({ + flag = "EVENT_GOT_TM39", item = "TM_SWIFT", + pre = "_Route12Gate2FBrunetteGirlYouCanHaveThisText", + received = "_Route12Gate2FBrunetteGirlReceivedTM39Text", + explain = "_Route12Gate2FBrunetteGirlTM39ExplanationText", + noRoom = "_Route12Gate2FBrunetteGirlTM39NoRoomText", + }), + }, +} + +-- TM41 Softboiled (scripts/CeladonCity.asm, Gramps3) +M.CELADON_CITY = { + talk = { + TEXT_CELADONCITY_GRAMPS3 = gift({ + flag = "EVENT_GOT_TM41", item = "TM_SOFTBOILED", + pre = "_CeladonCityGramps3Text", + received = "_CeladonCityGramps3ReceivedTM41Text", + explain = "_CeladonCityGramps3TM41ExplanationText", + noRoom = "_CeladonCityGramps3TM41NoRoomText", + }), + }, +} + +-- TM35 Metronome (scripts/CinnabarLabMetronomeRoom.asm) +M.CINNABAR_LAB_METRONOME_ROOM = { + talk = { + TEXT_CINNABARLABMETRONOMEROOM_SCIENTIST1 = gift({ + flag = "EVENT_GOT_TM35", item = "TM_METRONOME", + pre = "_CinnabarLabMetronomeRoomScientist1Text", + received = "_CinnabarLabMetronomeRoomScientist1ReceivedTM35Text", + explain = "_CinnabarLabMetronomeRoomScientist1TM35ExplanationText", + noRoom = "_CinnabarLabMetronomeRoomScientist1TM35NoRoomText", + }), + }, +} + +-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text) +M.VIRIDIAN_CITY = { + talk = { + TEXT_VIRIDIANCITY_FISHER = gift({ + flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER", + received = "_ViridianCityFisherReceivedTM42Text", + explain = "_ViridianCityFisherTM42ExplanationText", + noRoom = "_ViridianCityFisherTM42NoRoomText", + }), + }, +} + +-- TM36 Selfdestruct (scripts/SilphCo2F.asm, the rescued worker) +M.SILPH_CO_2F = { + talk = { + TEXT_SILPHCO2F_SILPH_WORKER_F = gift({ + flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT", + received = "_SilphCo2FSilphWorkerFReceivedTM36Text", + explain = "_SilphCo2FSilphWorkerFTM36ExplanationText", + noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText", + }), + }, +} + +-- 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) +M.ROUTE_1 = { + talk = { + TEXT_ROUTE1_YOUNGSTER1 = gift({ + flag = "EVENT_GOT_POTION_SAMPLE", item = "POTION", + pre = "_Route1Youngster1MartSampleText", + received = "_Route1Youngster1GotPotionText", + noRoom = "_Route1Youngster1NoRoomText", + already = "_Route1Youngster1AlsoGotPokeballsText", + }), + }, +} + +-- The three in-game trades the port was missing (data/events/trades.asm +-- indices are 1-based in data/generated/field.lua trades) +M.ROUTE_11_GATE_2F = { + talk = { + TEXT_ROUTE11GATE2F_YOUNGSTER = { + { "face_player" }, + { "trade", 1, "EVENT_TRADED_NIDORINO_FOR_NIDORINA" }, -- TERRY + }, + }, +} + +M.ROUTE_18_GATE_2F = { + talk = { + TEXT_ROUTE18GATE2F_YOUNGSTER = { + { "face_player" }, + { "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC + }, + }, +} + +M.UNDERGROUND_PATH_ROUTE_5 = { + talk = { + TEXT_UNDERGROUNDPATHROUTE5_LITTLE_GIRL = { + { "face_player" }, + { "trade", 10, "EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F" }, -- SPOT + }, + }, +} + +-- ===================================================================== +-- Progression gates and rival ambushes +-- ===================================================================== + +local function inCoords(coords, x, y) + for _, c in ipairs(coords) do + if x == c[1] and y == c[2] then return true end + end + return false +end + +-- coordinate block: show a line and push the player back one step +local function stepGate(opts) + return function(game, ow, x, y) + if not inCoords(opts.coords, x, y) then return false end + if not opts.blocked(game) then return false end + require("src.core.Sound").play(game.data, "Denied") + push(game, text(game)[opts.text] or opts.fallback, + function() ow:scriptMove(ow.player, opts.push, 1) end) + return true + end +end + +-- Viridian Gym stays locked until the seven other badges are earned +-- (scripts/ViridianCity.asm ViridianCityCheckGymOpenScript: wObtained- +-- Badges == ~EARTHBADGE at (32,8) shoves the player off the door) +local SEVEN_BADGES = { "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", + "VOLCANOBADGE" } +local viridianGymLock = stepGate({ + coords = { { 32, 8 } }, + blocked = function(game) + for _, b in ipairs(SEVEN_BADGES) do + if not game.save.inventory[b] then return true end + end + return false + end, + text = "_ViridianCityGymLockedText", + fallback = "The GYM's doors\nare locked...", + push = "down", +}) + +-- story.lua's VIRIDIAN_CITY module owns the sleeping-old-man block; +-- chain it behind the gym lock (the registry keeps one onStep per map) +local viridianOldManStep = require("data.scripts.story").VIRIDIAN_CITY.onStep +M.VIRIDIAN_CITY.onStep = function(game, ow, x, y) + if viridianGymLock(game, ow, x, y) then return true end + return viridianOldManStep(game, ow, x, y) +end + +-- Cinnabar Gym needs the SECRET KEY (scripts/CinnabarIsland.asm) +M.CINNABAR_ISLAND = { + onStep = stepGate({ + coords = { { 18, 4 } }, + blocked = function(game) return not game.save.inventory.SECRET_KEY end, + text = "_CinnabarIslandDoorIsLockedText", + fallback = "The door is\nlocked...", + push = "down", + }), + -- CinnabarIsland_Script line 6: ResetEvent EVENT_LAB_STILL_REVIVING_ + -- FOSSIL on every (re)load of this map -- OverworldState:setMap runs + -- onEnter on every entry, not just the first, so this fires both + -- when the player walks out of the fossil lab back onto the island + -- and on any later re-entry (boat, Mansion exit, etc.), matching the + -- oracle's every-load map script. Paired with the deposit/pending/ + -- ready state machine in data/scripts/story2.lua's + -- TEXT_CINNABARLABFOSSILROOM_SCIENTIST1. + onEnter = function(game, ow) + if game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL then + game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL = nil + end + end, +} + +-- Pewter's youngster stops you leaving east before Brock is beaten +-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the +-- original escorts you to the gym, we walk you back a step) +M.PEWTER_CITY = { + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_BROCK then return false end + if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then + return false + end + local t = text(game) + push(game, t._PewterCityYoungsterYoureATrainerFollowMeText + or "Hey! You're a\ntrainer, right?", function() + push(game, t._PewterCityYoungsterGoTakeOnBrockText + or "Go take on BROCK\nat the GYM first!", function() + ow:scriptMove(ow.player, "left", 1) + end) + end) + return true + end, +} + +-- Rival ambush: show the hidden rival, walk him up to the player, run +-- the battle rows, march him back and hide him. On a loss the walk is +-- skipped (the blackout rebuilds the map mid-script). +local function runAmbush(game, ow, rows, playerFacing) + if ow.runner:isRunning() then return false end + ow.player.facing = playerFacing + -- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music + -- takes over and the map theme returns after the victory jingle + require("src.core.Music").play(game.data, "Music_MeetRival") + ow.runner:run(rows) + return true +end + +-- Route 22 rival, both visits (scripts/Route22.asm). pokered arms +-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter +-- Gym) and again in Viridian Gym; we derive the same windows from the +-- surrounding story flags so old saves work too. +local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py) + return { + { "show_object", "ROUTE_22", objName }, -- 1 + { "move_npc_to", objIndex, 28, py }, -- 2 + { "face_object", objIndex, "right" }, -- 3 + { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 + { "rival_battle", oppClass, baseParty }, -- 5 + { "jump_if_false", 11 }, -- 6 + { "set_flag", beatFlag }, -- 7 + { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 + { "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 + { "move_npc_to", objIndex, 25, 5 }, -- 10 + { "hide_object", "ROUTE_22", objName }, -- 11 + } +end + +M.ROUTE_22 = { + onStep = function(game, ow, x, y) + if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end + local f = game.save.flags + if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK + and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then + return runAmbush(game, ow, + route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4, + "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left") + end + if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then + return runAmbush(game, ow, + route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10, + "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left") + end + return false + end, +} + +-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2), +-- the TM28 rocket thief with his forced-fight cells (Coords1), and the +-- cave guard who steps aside once you are Champion. +local function ceruleanRivalScene(px, py) + return { + { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 + { "move_npc_to", 1, px, py - 1 }, -- 2 + { "face_object", 1, "down" }, -- 3 + { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 + { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 + { "jump_if_false", 10 }, -- 6 + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 + { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 + { "move_npc_to", 1, 20, 2 }, -- 9 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10 + } +end + +-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, +-- then he returns TM28 (DIG) and hurries off +local rocketRows = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_TM28" }, -- 2 + { "jump_if_true", 15 }, -- 3 + { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 + { "jump_if_true", 9 }, -- 5 + { "show_text", "_CeruleanCityRocketText" }, -- 6 + { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 + { "jump_if_false", 16 }, -- 8 + { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 + { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 + { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) + { "set_flag", "EVENT_GOT_TM28" }, -- 12 + { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 + { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15 +} + +M.CERULEAN_CITY = { + talk = { + TEXT_CERULEANCITY_ROCKET = rocketRows, + }, + onEnter = function(game, ow) + if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL then + local Commands = require("src.script.Commands") + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + "CERULEAN_CITY", "CERULEANCITY_SUPER_NERD3") + end + end, + onStep = function(game, ow, x, y) + local f = game.save.flags + if not f.EVENT_BEAT_CERULEAN_ROCKET_THIEF + and inCoords({ { 30, 7 }, { 30, 9 } }, x, y) then + if ow.runner:isRunning() then return false end + local rocket = ow:npcByIndex(2) + if rocket then + ow.player.facing = y < 8 and "down" or "up" + ow.runner:run(rocketRows, { npc = rocket }) + return true + end + return false + end + if not f.EVENT_BEAT_CERULEAN_RIVAL + and inCoords({ { 20, 6 }, { 21, 6 } }, x, y) then + return runAmbush(game, ow, ceruleanRivalScene(x, y), "up") + end + return false + end, +} + +-- The Pewter museum's fossil exhibits (engine/events/hidden_events/ +-- museum_fossils.asm: DisplayMonFrontSpriteInBox + the plaque text) +M.MUSEUM_1F = { + onInteract = function(game, ow, fx, fy) + if ow.player.facing ~= "up" then return false end + local PicBox = require("src.ui.PicBox") + local t = text(game) + if fx == 2 and fy == 3 then + game.stack:push(PicBox.new(game, + "assets/generated/battle/front/fossilaerodactyl.png", + t._AerodactylFossilText or "AERODACTYL Fossil")) + return true + end + if fx == 2 and fy == 6 then + game.stack:push(PicBox.new(game, + "assets/generated/battle/front/fossilkabutops.png", + t._KabutopsFossilText or "KABUTOPS Fossil")) + return true + end + return false + end, +} + +-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm +-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes) +M.PEWTER_POKECENTER = { + talk = { + TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done) + require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong") + push(game, text(game)._PewterPokecenterJigglypuffText + or "JIGGLYPUFF: Puu\npupuu!", done) + end, + }, +} + +-- Cycling Road gate guards (scripts/Route16Gate1F.asm / +-- Route18Gate1F.asm): without a BICYCLE in the bag the guard stops you +-- on the west-side cells and walks you back +local function bikeGateGuard(coords, stopText, explainText) + return function(game, ow, x, y) + if game.save.inventory.BICYCLE then return false end + if not inCoords(coords, x, y) then return false end + local t = text(game) + push(game, t[stopText] or "Hey! Wait up!", function() + push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function() + ow:scriptMove(ow.player, "up", 1) + end) + end) + return true + end +end + +M.ROUTE_16_GATE_1F = { + onStep = bikeGateGuard( + { { 4, 7 }, { 4, 8 }, { 4, 9 }, { 4, 10 } }, + "_Route16Gate1FGuardWaitUpText", + "_Route16Gate1FGuardNoPedestriansAllowedText"), +} + +M.ROUTE_18_GATE_1F = { + onStep = bikeGateGuard( + { { 4, 3 }, { 4, 4 }, { 4, 5 }, { 4, 6 } }, + "_Route18Gate1FGuardExcuseMeText", + "_Route18Gate1FGuardYouNeedABicycleText"), +} + +-- Silph Co. 7F rival ambush (scripts/SilphCo7F.asm +-- SilphCo7FDefaultScript: coords (3,2)/(3,3), the rival at (3,7) walks +-- up, MUSIC_MEET_RIVAL, OPP_RIVAL2 parties 7-9 by starter, then he +-- wishes you luck, walks off right and disappears; one-time via +-- EVENT_BEAT_SILPH_CO_RIVAL) +M.SILPH_CO_7F = { + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_SILPH_CO_RIVAL then return false end + if not inCoords({ { 3, 2 }, { 3, 3 } }, x, y) then return false end + return runAmbush(game, ow, { + { "show_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 1 + { "show_text", "_SilphCo7FRivalText" }, -- 2 + { "move_npc_to", 9, 3, y + 1 }, -- 3 + { "face_object", 9, "up" }, -- 4 + { "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5 + { "rival_battle", "OPP_RIVAL2", 7 }, -- 6 + { "jump_if_false", 12 }, -- 7 + { "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8 + { "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9 + { "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10 + { "move_npc_to", 9, 5, y + 1 }, -- 11 + { "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12 + }, "down") + end, +} + +-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8) +M.SS_ANNE_2F = { + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL then return false end + if not inCoords({ { 36, 8 }, { 37, 8 } }, x, y) then return false end + local onLeft = x == 36 + return runAmbush(game, ow, { + { "show_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 1 + { "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2 + { "face_object", 2, onLeft and "down" or "right" }, -- 3 + { "show_text", "_SSAnne2FRivalText" }, -- 4 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 + { "jump_if_false", 10 }, -- 6 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 + { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 + { "move_npc_to", 2, 36, 4 }, -- 9 + { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10 + }, onLeft and "up" or "left") + end, +} + +return M diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua new file mode 100644 index 00000000..297a711e --- /dev/null +++ b/data/scripts/story6.lua @@ -0,0 +1,280 @@ +-- Sixth batch of hand-ported map scripts (parity sweep 2026-07-12): +-- the Pokémon Mansion statue switches, the Cinnabar Gym quiz doors, +-- and the Indigo Plateau lobby's Elite Four rematch reset. Each cites +-- its pokered source. + +local M = {} + +local function text(game) return game.data.text end + +local function push(game, s, done) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, done)) +end + +local function ask(game, s, cb) + local ChoiceBox = require("src.ui.ChoiceBox") + push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) +end + +-- ------------------------------------------------------------------- +-- Pokémon Mansion switches (scripts/PokemonMansion1F/2F/3F/B1F.asm): +-- one shared toggle, EVENT_MANSION_SWITCH_ON, flips door/wall blocks +-- on all four floors. Each floor's Mansion*CheckReplaceSwitchDoorBlocks +-- runs on map load, so the state persists purely through the event +-- flag; pressing a switch toggles it (CheckAndSetEvent/ResetEvent) and +-- reapplies the current floor's blocks. +-- Block ids: $2d horizontal gate (1F/B1F "door" wall), $54 horizontal +-- gate, $5f vertical gate, $e open floor. Coords below are +-- ReplaceTileBlock's (X, Y) block coordinates (asm passes b=Y, c=X). +-- ------------------------------------------------------------------- + +-- per floor: { bx, by, offBlock, onBlock } +local MANSION_BLOCKS = { + POKEMON_MANSION_1F = { + { 12, 6, 0x0e, 0x2d }, + { 8, 3, 0x2d, 0x0e }, + { 10, 8, 0x2d, 0x0e }, + { 13, 13, 0x2d, 0x0e }, + }, + POKEMON_MANSION_2F = { + { 4, 2, 0x0e, 0x5f }, + { 9, 4, 0x54, 0x0e }, + { 3, 11, 0x5f, 0x0e }, + }, + POKEMON_MANSION_3F = { + { 7, 2, 0x0e, 0x5f }, + { 7, 5, 0x5f, 0x0e }, + }, + POKEMON_MANSION_B1F = { + { 13, 8, 0x0e, 0x2d }, + { 6, 11, 0x0e, 0x5f }, + { 4, 3, 0x5f, 0x0e }, + { 8, 8, 0x54, 0x0e }, + }, +} + +local function applyMansionBlocks(game, ow) + local rows = MANSION_BLOCKS[ow.map.id] + if not rows then return end + local on = game.save.flags.EVENT_MANSION_SWITCH_ON + for _, r in ipairs(rows) do + ow:replaceBlock(r[1], r[2], on and r[4] or r[3]) + end +end + +-- switch statues (data/events/hidden_events.asm Mansion*Script_Switches, +-- all facing up); 2F/3F/B1F reuse the 2F switch text in pokered +local function mansionFloor(switchCoords, textPrefix) + return { + onEnter = applyMansionBlocks, + onInteract = function(game, ow, fx, fy) + if ow.player.facing ~= "up" then return false end + local hit = false + for _, c in ipairs(switchCoords) do + if fx == c[1] and fy == c[2] then hit = true break end + end + if not hit then return false end + local t = text(game) + ask(game, t[textPrefix .. "SwitchText"] or "A secret switch!\fPress it?", + function(yes) + if not yes then + push(game, t[textPrefix .. "SwitchNotPressedText"] + or "Not quite yet!") + return + end + local f = game.save.flags + if f.EVENT_MANSION_SWITCH_ON then + f.EVENT_MANSION_SWITCH_ON = nil + else + f.EVENT_MANSION_SWITCH_ON = true + end + require("src.core.Sound").play(game.data, "Go_Inside") + applyMansionBlocks(game, ow) + push(game, t[textPrefix .. "SwitchPressedText"] or "Who wouldn't?") + end) + return true + end, + } +end + +M.POKEMON_MANSION_1F = mansionFloor({ { 2, 5 } }, "_PokemonMansion1F") +M.POKEMON_MANSION_2F = mansionFloor({ { 2, 11 } }, "_PokemonMansion2F") +M.POKEMON_MANSION_3F = mansionFloor({ { 10, 5 } }, "_PokemonMansion2F") +M.POKEMON_MANSION_B1F = mansionFloor({ { 20, 3 }, { 18, 25 } }, + "_PokemonMansion2F") + +-- 3F floor holes (PokemonMansion3FDefaultScript.holeCoords + +-- data/maps/special_warps.asm DungeonWarpData): stepping on a hole +-- drops the player -- (16,14)/(17,14) land on 1F at (16,14), the only +-- way into the sealed basement-stairs room; (19,14) lands on 2F at +-- (18,14). +local MANSION_HOLES = { + { 16, 14, "POKEMON_MANSION_1F", 16, 14 }, + { 17, 14, "POKEMON_MANSION_1F", 16, 14 }, + { 19, 14, "POKEMON_MANSION_2F", 18, 14 }, +} + +M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y) + for _, h in ipairs(MANSION_HOLES) do + if x == h[1] and y == h[2] then + ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) + return true + end + end + return false +end + +-- ------------------------------------------------------------------- +-- Cinnabar Gym quiz doors (engine/events/hidden_events/ +-- cinnabar_gym_quiz.asm + scripts/CinnabarGym.asm): six quiz machines; +-- a correct answer opens that room's gate block, a wrong one plays +-- SFX_DENIED and sics the room's trainer on you. Beating the trainer +-- (via the quiz or by talking to him) also opens the gate +-- (CinnabarGymOpenGateScript). Gates persist via per-door event flags. +-- ------------------------------------------------------------------- + +local GYM_OPEN_BLOCK = 0x0e + +-- machine i: quiz tile (x,y), whether YES is correct (answer nibble +-- FALSE = menu item 0 = YES), the gate's block (x,y,closed id) from +-- CinnabarGymGateCoords, and the guarding trainer's object index +-- (wOpponentAfterWrongAnswer = gate index + 2 = SUPER_NERD(i+1)) +local GYM_MACHINES = { + { x = 15, y = 7, yes = true, gate = { 9, 3, 0x54 }, npc = 3 }, + { x = 10, y = 1, yes = false, gate = { 6, 3, 0x54 }, npc = 4 }, + { x = 9, y = 7, yes = false, gate = { 6, 6, 0x54 }, npc = 5 }, + { x = 9, y = 13, yes = false, gate = { 3, 8, 0x5f }, npc = 6 }, + { x = 1, y = 13, yes = true, gate = { 2, 6, 0x54 }, npc = 7 }, + { x = 1, y = 7, yes = false, gate = { 2, 3, 0x54 }, npc = 8 }, +} + +local function gymGateFlag(i) + return "EVENT_CINNABAR_GYM_GATE" .. (i - 1) .. "_UNLOCKED" +end + +local function applyGymGates(game, ow) + for i, m in ipairs(GYM_MACHINES) do + local open = game.save.flags[gymGateFlag(i)] + or game.save.defeatedTrainers["CINNABAR_GYM_obj_" .. m.npc] + ow:replaceBlock(m.gate[1], m.gate[2], open and GYM_OPEN_BLOCK or m.gate[3]) + end +end + +-- beating a guardian opens his gate like CinnabarGymOpenGateScript +local function syncGymGatesAfterBattle(game, ow) + for i, m in ipairs(GYM_MACHINES) do + if game.save.defeatedTrainers["CINNABAR_GYM_obj_" .. m.npc] + and not game.save.flags[gymGateFlag(i)] then + game.save.flags[gymGateFlag(i)] = true + require("src.core.Sound").play(game.data, "Go_Inside") + end + end + applyGymGates(game, ow) +end + +M.CINNABAR_GYM = { + onEnter = applyGymGates, + onVictory = syncGymGatesAfterBattle, + onInteract = function(game, ow, fx, fy) + if ow.player.facing ~= "up" then return false end + local index, machine + for i, m in ipairs(GYM_MACHINES) do + if fx == m.x and fy == m.y then index, machine = i, m break end + end + if not machine then return false end + local t = text(game) + local Sound = require("src.core.Sound") + push(game, t._CinnabarGymQuizIntroText + or "POKéMON Quiz!\fGet it right and\nthe door opens!", function() + ask(game, t["_CinnabarQuizQuestionsText" .. index] or "Well?", + function(yes) + if yes == machine.yes then + -- CinnabarGymQuizCorrectText: item jingle, then the gate + -- slides open (SFX_GO_INSIDE) if it was still locked + Sound.play(game.data, "Get_Item1") + push(game, t._CinnabarGymQuizCorrectText + or "You're absolutely\ncorrect!\fGo on through!", function() + if not game.save.flags[gymGateFlag(index)] then + game.save.flags[gymGateFlag(index)] = true + Sound.play(game.data, "Go_Inside") + end + applyGymGates(game, ow) + end) + return + end + Sound.play(game.data, "Denied") + push(game, t._CinnabarGymQuizIncorrectText or "Sorry! Bad call!", + function() + local npc = ow:npcByIndex(machine.npc) + if npc and not ow:trainerDefeated(npc) then + ow:engageTrainer(npc, function() end) + end + end) + end) + end) + return true + end, +} + +-- ------------------------------------------------------------------- +-- Indigo Plateau lobby: the Elite Four rematch reset +-- (scripts/IndigoPlateauLobby.asm: on entry, ResetEvent +-- EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH; then, if BIT_STARTED_ELITE_4 +-- is set, clear it and ResetEventRange INDIGO_PLATEAU_EVENTS_START .. +-- EVENT_LANCES_ROOM_LOCK_DOOR so the whole league can be re-fought. +-- BIT_STARTED_ELITE_4 is set whenever Lorelei's room loads +-- (LoreleiShowOrHideExitBlock). +-- ------------------------------------------------------------------- + +-- constants/event_constants.asm $8E0..EVENT_LANCES_ROOM_LOCK_DOOR +local E4_RESET_FLAGS = { + "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM", + "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM", + "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM", + "EVENT_BEAT_LANCES_ROOM_TRAINER_0", "EVENT_BEAT_LANCE", + "EVENT_LANCES_ROOM_LOCK_DOOR", + -- port-side: the run-scoped champion gate (EVENT_BEAT_CHAMPION_RIVAL + -- itself stays set, like pokered's out-of-range flag) + "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN", +} + +local E4_TRAINER_KEYS = { + "LORELEIS_ROOM_obj_1", "BRUNOS_ROOM_obj_1", + "AGATHAS_ROOM_obj_1", "LANCES_ROOM_obj_1", +} + +M.INDIGO_PLATEAU_LOBBY = { + onEnter = function(game, ow) + local f = game.save.flags + f.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil + -- old saves from before EVENT_STARTED_ELITE_4 existed derive it + -- from the run's progress flags + local started = f.EVENT_STARTED_ELITE_4 + if not started then + for _, flag in ipairs(E4_RESET_FLAGS) do + if f[flag] then started = true break end + end + end + if not started then return end + f.EVENT_STARTED_ELITE_4 = nil + for _, flag in ipairs(E4_RESET_FLAGS) do f[flag] = nil end + for _, key in ipairs(E4_TRAINER_KEYS) do + game.save.defeatedTrainers[key] = nil + end + end, +} + +-- Lorelei's room load marks the challenge as started (the wElite4Flags +-- bit in LoreleiShowOrHideExitBlock); the exit-seal onEnter from +-- story4.lua still runs +local loreleisRoom = require("data.scripts.story4").LORELEIS_ROOM +local loreleiSeal = loreleisRoom.onEnter +M.LORELEIS_ROOM = { + onEnter = function(game, ow) + game.save.flags.EVENT_STARTED_ELITE_4 = true + loreleiSeal(game, ow) + end, +} + +return M diff --git a/data/scripts/story7.lua b/data/scripts/story7.lua new file mode 100644 index 00000000..f569e81a --- /dev/null +++ b/data/scripts/story7.lua @@ -0,0 +1,120 @@ +-- Gym-guide NPCs: the helpful trainer stationed near each gym's door. +-- Each pokered GymGuideText is a text_asm that branches on whether the +-- leader's badge has already been earned; only Pewter's guide also asks +-- a YES/NO question first (the answer only changes the transitional +-- line -- the advice text that follows is the same either way). +-- +-- Row-list rows use the numeric-jump-target style from +-- data/scripts/oaks_lab.lua; see src/script/ScriptRunner.lua for how +-- jump/jump_if_true/jump_if_false select the next row (1-based index). + +local M = {} + +-- Common "beaten the leader? -> congratulations : champ-in-making advice" +-- shape shared by 7 of the 8 guides (CheckEvent EVENT_BEAT_ ...). +local function badgeBranch(beatFlag, champText, beatText) + return { + { "check_flag", beatFlag }, -- 1 + { "jump_if_true", 5 }, -- 2 + { "show_text", champText }, -- 3 + { "jump", 6 }, -- 4 (skip the beaten-text row) + { "show_text", beatText }, -- 5 + } +end + +M.CERULEAN_GYM = { + talk = { + -- scripts/CeruleanGym.asm CeruleanGymGymGuideText + TEXT_CERULEANGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_MISTY", + "_CeruleanGymGymGuideChampInMakingText", + "_CeruleanGymGymGuideBeatMistyText"), + }, +} + +M.CINNABAR_GYM = { + talk = { + -- scripts/CinnabarGym.asm CinnabarGymGymGuideText + TEXT_CINNABARGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_BLAINE", + "_CinnabarGymGymGuideChampInMakingText", + "_CinnabarGymGymGuideBeatBlaineText"), + }, +} + +M.FUCHSIA_GYM = { + talk = { + -- scripts/FuchsiaGym.asm FuchsiaGymGymGuideText + TEXT_FUCHSIAGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_KOGA", + "_FuchsiaGymGymGuideChampInMakingText", + "_FuchsiaGymGymGuideBeatKogaText"), + }, +} + +-- TEXT_GAMECORNER_GYM_GUIDE is displayed on the GAME_CORNER map (the +-- gym guide object_event stands just inside the Game Corner door, next +-- to the stairs down to Celadon Gym) -- scripts/GameCorner.asm +-- GameCornerGymGuideText, gated on Celadon's leader ERIKA. +M.GAME_CORNER = { + talk = { + TEXT_GAMECORNER_GYM_GUIDE = badgeBranch("EVENT_BEAT_ERIKA", + "_GameCornerGymGuideChampInMakingText", + "_GameCornerGymGuideTheyOfferRarePokemonText"), + }, +} + +M.SAFFRON_GYM = { + talk = { + -- scripts/SaffronGym.asm SaffronGymGymGuideText + TEXT_SAFFRONGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_SABRINA", + "_SaffronGymGuideChampInMakingText", + "_SaffronGymGuideBeatSabrinaText"), + }, +} + +M.VERMILION_GYM = { + talk = { + -- scripts/VermilionGym.asm VermilionGymGymGuideText + -- (checks wBeatGymFlags BIT_THUNDERBADGE rather than CheckEvent, but + -- it's the same underlying condition as EVENT_BEAT_LT_SURGE) + TEXT_VERMILIONGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_LT_SURGE", + "_VermilionGymGymGuideChampInMakingText", + "_VermilionGymGymGuideBeatLTSurgeText"), + }, +} + +M.VIRIDIAN_GYM = { + talk = { + -- scripts/ViridianGym.asm ViridianGymGymGuideText + -- (checks EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI in pokered; the port's + -- equivalent flag set on winning that battle is EVENT_BEAT_GIOVANNI, + -- see data/scripts/victories.lua OPP_GIOVANNI#3) + TEXT_VIRIDIANGYM_GYM_GUIDE = badgeBranch("EVENT_BEAT_GIOVANNI", + "_ViridianGymGuidePreBattleText", + "_ViridianGymGuidePostBattleText"), + }, +} + +-- Pewter's guide (scripts/PewterGym.asm PewterGymGuideText) is the one +-- with a real YES/NO branch: before the badge is earned he asks (via +-- PrintText + YesNoChoice on the "I'm no trainer, but I can tell you +-- how to win!" text), and BOTH answers lead into the same advice text -- +-- only the one-line lead-in differs ("All right! Let's get happening!" +-- on YES vs. "It's a free service! Let's get happening!" on NO). Once +-- BOULDERBADGE is set he just congratulates you. +M.PEWTER_GYM = { + talk = { + TEXT_PEWTERGYM_GYM_GUIDE = { + { "check_flag", "EVENT_BEAT_BROCK" }, -- 1 + { "jump_if_true", 10 }, -- 2 + { "ask", "_PewterGymGuidePreAdviceText" }, -- 3 + { "jump_if_false", 7 }, -- 4 + { "show_text", "_PewterGymGuideBeginAdviceText" }, -- 5 (YES) + { "jump", 8 }, -- 6 + { "show_text", "_PewterGymGuideFreeServiceText" }, -- 7 (NO) + { "show_text", "_PewterGymGuideAdviceText" }, -- 8 (common) + { "jump", 11 }, -- 9 + { "show_text", "_PewterGymGuidePostBattleText" }, -- 10 (beaten) + }, + }, +} + +return M diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua new file mode 100644 index 00000000..98be5682 --- /dev/null +++ b/data/scripts/victories.lua @@ -0,0 +1,42 @@ +-- Rewards for winning specific trainer battles, keyed by +-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported +-- from the leaders'/bosses' text_asm victory scripts: +-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm +-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him +-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed. +-- The TM each gym leader hands out afterwards is also ported. + +return { + ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", + item = "TM_BIDE" }, + ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", + item = "TM_BUBBLEBEAM" }, + ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", + item = "TM_THUNDERBOLT" }, + ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", + item = "TM_MEGA_DRAIN" }, + ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", + item = "TM_TOXIC" }, + ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", + item = "TM_PSYWAVE" }, + ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", + item = "TM_FIRE_BLAST" }, + ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", + item = "TM_FISSURE" }, + + -- Silph Co. Giovanni: unlocks the president's Master Ball gift + ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + + -- Fighting Dojo Karate Master (scripts/FightingDojo.asm + -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, + -- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is + -- only him (data/maps/objects/FightingDojo.asm). + ["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" }, + + -- Elite Four progress flags (their rooms' door logic isn't ported, but + -- the flags make the Hall of Fame checkable) + ["OPP_LORELEI#1"] = { flag = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + ["OPP_BRUNO#1"] = { flag = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + ["OPP_AGATHA#1"] = { flag = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + ["OPP_LANCE#1"] = { flag = "EVENT_BEAT_LANCE" }, +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..e0fcc029 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,100 @@ +# Architecture + +```text +packaged first boot +user-provided Pokemon Red ROM + | + v +RomImporter + RomExtractor (Lua) + | + +--> private data/generated/*.lua + +--> private assets/generated/**/*.png + +--> private assets/generated/audio/programs.bin + | + v +LÖVE2D engine + ChipAudio +``` + +The importer validates the ROM SHA-1, decodes tables and graphics using +bundled address/name metadata, and writes a private cache. It releases the ROM +after import and does not copy it into the cache. Normal gameplay reads only +the generated files. + +`tools/build_data.py` is a separate Python/Pillow developer path that writes +the same core data and graphics into the source tree for verification. + +## Runtime layout + +| Area | Files | Role | +| --- | --- | --- | +| import | `src/import/RomImporter.lua` | first-boot UI, ROM validation, cache ownership | +| | `src/import/RomExtractor.lua` | ROM tables, text, pictures, PNGs, audio programs | +| core | `src/core/Game.lua` | service owner: data, input, renderer, stack, save | +| | `src/core/Data.lua` | loads `data/generated/*`, resolves TEXT_* pointers | +| | `src/core/ChipAudio.lua` | streams ROM music programs and synthesizes SFX/cries | +| | `src/core/FixedStep.lua` | 60 Hz fixed-step loop | +| | `src/core/Input.lua` | GB button abstraction, per-step edge detection | +| | `src/core/StateStack.lua` | stack of states; top updates, draws bottom-up from the last opaque state | +| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir | +| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling | +| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring | +| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing | +| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) | +| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page | +| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades | +| world | `src/world/Map.lua` | cell queries: walkable/grass/door/warp/sign (bottom-left-tile rule) | +| | `src/world/MapLoader.lua` | generated def -> runtime Map, cached | +| | `src/world/Player.lua`, `NPC.lua` | grid movement, walk animation, wander AI | +| | `src/world/Collision.lua` | tile + entity + bounds checks | +| | `src/world/Warp.lua` | arrive-on-door and walk-off-edge warp rules, LAST_MAP | +| | `src/world/Encounter.lua` | Gen 1 encounter rate + slot buckets | +| | `src/world/OverworldController.lua` | the overworld state: input, interactions, connections, encounters | +| script | `src/script/ScriptRunner.lua` | coroutine executor for command lists | +| | `src/script/Commands.lua` | show_text, flags, battles, warps, movement, objects... | +| | `src/script/Flags.lua` | named event flags in the save | +| pokemon | `src/pokemon/*` | instances, Gen 1 stat calc, growth curves, party | +| battle | `src/battle/BattleState.lua` | battle flow + menus + message queue | +| | `src/battle/Damage.lua` | Gen 1 damage/crit/accuracy formulas | +| | `src/battle/TypeChart.lua`, `TurnOrder.lua`, `Status.lua`, `MoveEffects.lua` | subsystems | +| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI | +| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` | +| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists | +| | `tools/save-editor/` | Standalone save editor (`love . --editor`) | + +## Map scripts + +Map-specific behavior lives in `data/scripts/.lua`, keyed by the +TEXT_* constants from the map's object events. The engine dispatches a +talk interaction to (in order): + +1. a hand-ported script in `data/scripts/` (`{ talk = { TEXT_X = {...} } }`), +2. the generic trainer path (object has trainer args from `object_event`), +3. the extracted plain text via the map's text pointer table. + +Scripts are arrays of `{ "command", args... }` rows executed by a +coroutine so `show_text`, `ask`, `start_battle`, `warp`, `wait` block +naturally. Every hand-ported script cites its pokered source file. + +## Coordinates + +- **block**: 32x32 px, the unit of `.blk` layouts (`map.width/height`) +- **cell**: 16x16 px walk grid, the unit of all object/warp coordinates +- **tile**: 8x8 px graphics; a cell is 2x2 tiles, a block 4x4 + +A cell's behavior (collision, grass, door, warp tile) is decided by its +bottom-left 8x8 tile, matching the original engine's "tile at the +sprite's feet" checks. + +## Verification + +- `luajit tests/run_tests.lua` - headless behavior suite over real + generated data (collision, warps, text, stats, damage, growth, type + chart, encounters, a full scripted battle, save round-trip) using a + `love` API stub. +- `luajit tests/run_save_editor_tests.lua` (plus the task-specific suites) + - save editor pure logic and panel click tests. +- `POKEPORT_AUTOPILOT=1 love .` - scripted end-to-end run (walk Pallet + Town, read the sign, enter Oak's Lab, take a starter, beat the rival, + exit, cross into Route 1, win a wild battle) that captures screenshots. +- `POKEPORT_DRIVER=tests/drivers/audio_runtime_test.lua love .` - imports + and queues title music, a sound effect, and a Pokemon cry. diff --git a/docs/behavior-porting-notes.md b/docs/behavior-porting-notes.md new file mode 100644 index 00000000..8693bcef --- /dev/null +++ b/docs/behavior-porting-notes.md @@ -0,0 +1,515 @@ +# Behavior porting notes + +What was ported from pokered's engine code and where it came from. + +## Overworld + +- **Collision rule** (`home/overworld.asm` tile-in-front checks): a 16x16 + cell is passable when its bottom-left 8x8 tile is in the tileset's + `coll_tiles` list. Verified against Pallet Town's fences/houses/water + and Oak's Lab furniture. +- **Warp activation** (`home/overworld.asm` CheckWarpsNoCollision / + ExtraWarpCheck): a warp fires when arriving on a warp whose standing + tile is in the tileset's door or warp tile list, or when standing on a + warp and walking off the map edge (interior exit mats). Both paths are + data-driven from `door_tile_ids.asm` / `warp_tile_ids.asm`. +- **LAST_MAP warps** return to the remembered outdoor map/position, like + `wLastMap`. +- **Connections** (`map_header` connection directives): crossing an edge + places the player at `destCoord = curCoord - offset*2` cells on the + destination's opposite edge. +- **Movement**: tile-by-tile, 1 px/frame at 60 fps (16 frames per step), + tap-to-turn without stepping, hold-to-walk, input locked mid-step. +- **Wild encounters** (`engine/battle/wild_encounters.asm`): per grass + step, encounter iff `rand(0..255) < rate`; slot picked via the + cumulative buckets 51/102/141/166/191/216/229/242/253/256. +- **Initial object visibility** from `toggleable_objects.asm` (e.g. Oak + hidden in his lab), with `show_object`/`hide_object` script commands + persisting to the save like the missable-object bits. + +## Pokémon math (`engine/pokemon/calc_stats.asm`, `experience.asm`) + +- `stat = floor(((base + DV)*2 + floor(sqrt(statExp)/4)) * L / 100) + 5` + (HP: `+ L + 10`); HP DV from the low bits of the other four DVs. +- Growth curves use the exact cubic coefficients (MEDIUM_SLOW = + 1.2n^3 - 15n^2 + 100n - 140, etc). +- Exp gain = `floor(baseExp * level / 7)` (x1.5 for trainer battles); + defeated species' base stats accumulate as stat experience. + +## Battle core (`engine/battle/core.asm`) + +- Damage: `floor(floor(2L(x2 crit)/5 + 2) * power * atk / def / 50)` + capped at 997, `+2`, STAB x1.5, per-matchup type multipliers applied + sequentially (x10 fixed point), then `rand(217..255)/255` when + damage > 1. +- Critical hits: `rand(0..255) < baseSpeed/2` (x4 for Karate Chop, Razor + Leaf, Crabhammer, Slash, capped 255); crits double level and ignore + stat stages (gen1_faithful ruleset). +- Accuracy: `rand(0..255) < floor(acc*255/100)` after accuracy/evasion + stages, including the 1/256 miss at 100% accuracy (toggleable via the + `modern_clean` ruleset). +- Stat stages use the 25/28/33/40/50/66/100/150/.../400 multiplier table + (`data/battle/stat_modifiers.asm`). +- Physical/special split by type (special = Water/Grass/Fire/Ice/ + Electric/Psychic/Dragon). +- Status: paralysis speed/4 and 25% full para, burn halves physical + attack, poison/burn residual = maxHP/16, sleep 1-7 turns waking on the + lost turn, freeze permanent (as in Gen 1). +- Turn order: effective speed, coin-flip ties; Quick Attack first, + Counter last (Gen 1's only priorities). +- Run formula (`TryRunningFromBattle`): always escape if faster, + otherwise `floor(pSpd*32 / (eSpd/4)) + 30*attempts` vs `rand(0..255)`. +- Catching (`ItemUseBall`): ball-specific rand ranges (255/200/150), + status bonus 25/12, second roll `floor(maxHP*255/ballFactor) / + floor(HP/4)` capped 255. +- Prize money: class base money x last defeated mon's level + (`pic_pointers_money.asm`). + +## Battle move effects (engine/battle/core.asm, move_effects/*) + +- Mimic via Metronome (effects.asm:1203-1273): MimicEffect's + .letPlayerChooseMove branch snapshots wCurrentMenuItem before the + copy-picker menu opens and restores it afterward as the write index + into wBattleMonMoves. Since SelectMenuItem always writes + wCurrentMenuItem/wPlayerMoveListIndex together at the FIGHT-menu + confirm and nothing (including MetronomePickMove) touches either + variable during mid-move resolution, the reused value is always the + calling move's own slot, BattleState.lua's applyMimic fallback uses + self.moveIndex, frozen the same way, so a called Mimic (e.g. from + METRONOME in slot 3) overwrites the calling move's own slot, keeping + its PP, matching the Gen 1 quirk exactly. +- Multi-hit distribution 2/2/2/3/3/3/4/5 over rand(0..7); all hits reuse + the first damage roll (faithful). +- Recoil = damage/4 (Struggle /2); drain/Dream Eater heal = damage/2; + Dream Eater requires sleep. +- Fixed damage: SonicBoom 20, Dragon Rage 40, Seismic Toss/Night Shade = + level, Psywave rand(1 .. 1.5xlevel-1). +- OHKO deals 65535, fails against faster targets; Swift skips accuracy; + Jump Kick crash = 1 damage on miss; Explosion halves defense and + faints the user even on a miss; Hyper Beam skips recharge if it KOs. +- Charge moves (incl. Fly's invulnerable turn), trapping moves locking + the victim out of its turns, Thrash's 3-4 turn lock ending in + confusion, Bide's 2-3 turn store-and-double, Rage's permanent lock + with attack-up on being hit, Counter/Quick Attack priority. +- Side-effect chances: 26/256 (10%), 77/256 (30%), stat-down side + effects 85/256; Twineedle 20% poison. +- Substitute costs 1/4 max HP, absorbs damage, blocks status/stat/side + effects; screens double effective defense (bypassed by crits); Focus + Energy keeps the Gen 1 quarter-rate bug under gen1_faithful. +- Status: sleep 1-7 turns (wake turn is lost), freeze permanent, burn + halves physical attack, paralysis speed/4 + 25% full para, Toxic's + rising counter, Leech Seed transfer, confusion 2-5 turns with 50% + 40-power typeless self-hit. +- Trainer Pokémon use fixed DVs 9/8/8/8 (TrainerAI.asm convention). + +## Items (engine/items/item_effects.asm) + +- Potion family 20/50/200/full; drinks 50/60/80; status heals per item; + Revive half HP; Rare Candy = exact next-level exp with HP delta kept; + evolution stones use the extracted evos data; TMs single-use / HMs + reusable, gated by the species' real tmhm list; Repel 100/200/250 + steps blocking wilds below the lead's level; Escape Rope returns to + the last heal point. +- Snorlax (Route 12/16) only wakes via `ItemUsePokeFlute` (item-use + menu, adjacent to it, not yet beaten), talking to it with the POKé + FLUTE merely in the bag has no effect (`engine/items/item_effects.asm`, + `scripts/Route12.asm`/`Route16.asm`). +- Mart inventories come from the script_mart lists per clerk; selling + pays half price; TM prices from tm_prices.asm. + +## Overworld field systems + +- Ledges from ledge_tiles.asm (facing + standing tile + ledge tile + + input direction -> two-cell hop). +- Counter talk-through uses the tileset's counter tiles + (tileset_headers.asm), which is how mart clerks and nurses work. +- Trainer sight (`home/trainers.asm` CheckFightingMapTrainers + + `engine/overworld/trainer_sight.asm`): extracted per-trainer range, + inclusive tiles along the facing line; detection runs only on + tile-aligned frames, before input handling, so on detection the d-pad + is dead (wJoyIgnore) and the player freezes on the spotted tile; the + "!" holds 60 frames (EmotionBubble), then the trainer walks + distance−1 steps to the adjacent tile (none if already adjacent) and + uses the real battle/won/after dialogue from the trainer headers. + Sight is a pure screen-coordinate comparison with no line-of-sight + obstruction check (TrainerEngage / CheckSpriteCanSeePlayer): an + aligned in-range trainer engages through interposed NPCs and + unwalkable tiles, and the walk-up (TrainerWalkUpToPlayer, a fixed + distance−1 MoveSprite_ script) has no collision either, so the + trainer simply walks/overlaps through anything on the line, as OAM + sprites overlap on hardware. +- Elevator rides (`engine/overworld/elevator.asm` ShakeElevator → + `src/world/ElevatorShake.lua`): choosing a floor stops the music, + bounces the BG scroll ±1 px around rest for 100 two-frame cycles with + SFX_COLLISION retriggered every cycle, restores the scroll, plays + SFX_SAFARI_ZONE_PA to completion, and restarts the map theme before + the floor warp. Lead-in delays kept per script: 9 frames of Delay3s + inside ShakeElevator (Celadon farjps in), 12 with the Silph/Rocket + scripts' extra Delay3. The offset applies to the BG layer only, + sprites are OAM and stay put. After the ride the port no longer + jump-cuts: choosing a floor rewrites the car's own exit-warp entries + to that floor (`engine/events/elevator.asm` DisplayElevatorFloorMenu + .UpdateWarp, per scripts/SilphCoElevator.asm / + CeladonMartElevator.asm / RocketHideoutElevator.asm), then the player + is walked out through the doorway onto that warp (ow:scriptMove → + ow:takeWarp), like the original. +- Field-move gates (engine/overworld/field_move_messages.asm + + start_sub_menus.asm): IsSurfingAllowed ported exactly, SURF refuses + with _CyclingIsFunText while the Cycling Road's BIT_ALWAYS_ON_BIKE is + armed (save.forcedBike: set on the Route 16/18 forced-bike tiles, + cleared by the gates, Fly, dungeon/blackout warps; the forced mount + itself is silent, as in CheckForceBikeOrSurf) and with + _CurrentTooFastText on Seafoam B4F's stairs square (7,11) until both + EVENT_SEAFOAM4 boulders are down. Re-selecting SURF while surfing is + ItemUseSurfboard's dismount attempt: steps ashore silently if the + facing tile is land-passable and unoccupied, else "There's no place + to get off!", and the menu closes either way (wActionResult stays 1). + STRENGTH's first page auto-advances after the cry + Delay3 (no + prompt); "can move boulders." prompts. The GBPalWhiteOutWithDelay3 + white blink plays on every .goBackToMap closer: Strength, surf + mount/dismount/no-place, Flash (after its text), and Dig/Teleport + (Cut closes without a blink, per the asm). +- Wild slot table + rate per map; water encounter tables used while + surfing. +- Cut-tree block swaps from cut_tree_blocks.asm; surfable tilesets from + water_tilesets.asm (water tile $14, plus $32 on SHIP_PORT). + +## Story events (data/scripts/story.lua and friends) + +- Every hand-ported script cites its scripts/*.asm source and reuses the + real extracted text and event-flag names. +- Custom flag names (audited equivalent): three port-internal flag + families have no pokered EVENT constant but mirror the original's + state exactly. EVENT_TRADED_* are per-trade names for + wCompletedInGameTradeFlags bits (engine/events/in_game_trades.asm: + FLAG_TEST before the offer → after-trade text, FLAG_SET on completion; + dialogset text families, party-menu pick, the received mon joins the + end of the party, ConnectCable→anim→TradedFor→Thanks all ported). + EVENT_GOT_EEVEE is bookkeeping alongside the real guard, the hidden + ball object (scripts/CeladonMansionRoofHouse.asm HideObject, ≡ + save.objectToggles), and self-heals older saves; a full party+box + keeps the ball claimable (_BoxIsFullText). EVENT_BEAT_SS_ANNE_RIVAL + stands in for scripts/SSAnne2F.asm's saved wSSAnne2FCurScript NOOP + progression, including the lose-and-retrigger path (flag only set on + victory). Names are kept for save compatibility. Coverage: + tests/parity_trade_gift.lua. +- The Pallet Town intro follows pokered exactly: the trigger is + PalletTownDefaultScript's wYCoord==1 check, Oak appears at (8,5) and + takes FindPathToPlayer's zigzag to one tile below the player, and the + escort is RLEList_ProfOakWalkToLab against the reverse-order playback + of RLEList_PlayerWalkToLab (the 17th simulated press is eaten by the + door-warp frame), followed by the OaksLab walk-in and choose-mon + exchange with map music deferred like BIT_NO_MAP_MUSIC. Oak's speech + ends with the real shrink: RedPicFront collapses through the extracted + ShrinkPic1/ShrinkPic2 into the overworld walking sprite on + OakSpeech.asm's frame timings (SFX_SHRINK, 4/4/20/50-frame beats, fade + to white), with the closing text box held on screen. The escort's + scripted steps run 16 frames/tile (chained single-tile scriptMoves + start back-to-back, no idle frame); Oak marches in place on the door + mat for RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat + (movement.asm ChangeFacingDirection → zero-delta TryWalking); the "!" + EmotionBubble overlaps the still-shown "Hey! Wait!" box + (PalletTownOakText prints without a button wait, then DelayFrames 10 → + EmotionBubble before the box clears); and the shrink beat ramps the + music to silence over ~70 frames (wAudioFadeOutControl = 10; + home/fade_audio.asm FadeOutAudio steps rAUDVOL 7→0) rather than + hard-stopping. +- The 12 disguised static wild battles (Power Plant Voltorb/Electrode + + Zapdos, Articuno, Moltres, Mewtwo) follow TalkToTrainer/ + EndTrainerBattle exactly: cry + battle text, after-battle text without + a rematch once EVENT_BEAT_* is set, and the flag/HideObject on any + non-blackout result (fleeing loses the legendary, as in Gen 1). + Snorlax hides before its battle and only shows the calmed-down/ + returned line when not caught. Zapdos/Articuno/Moltres/Mewtwo's + battle text is a text_far string ending in a bare "...@" terminator + (no /) followed by text_asm PlayCry + WaitForSoundToFinish: + the box types with no ▼ prompt and auto-closes only once the cry + finishes, never on a button press, ported via `Commands.play_cry` + stashing the pending cry for the following `Commands.show_text` to + consume as the TextBox's auto-close sound. Voltorb/Electrode's battle + text has no PlayCry call in the ROM at all and keeps the ordinary + button-wait close. +- Gym leader repeat dialogue (data/scripts/gyms.lua): each leader's + text_asm branches on EVENT_BEAT_, pre-badge talk prints the + pre-battle text and engages the leader battle (badge/TM via + data/scripts/victories.lua); post-badge talk prints the leader's + post-battle advice text (Misty's is her TM11 explanation). The + originals' middle branch (beaten but TM not handed over) is + unreachable since the TM is granted with the victory. Giovanni's + farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a + fade-to-black/fade-in Transition matching ViridianGym.asm's + GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted + permanently via TOGGLE_VIRIDIAN_GYM_GIOVANNI in save.objectToggles. +- Cable Club receptionists (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST → + CableClubNPC, all 12 Pokémon Centers): welcome, pre-Pokédex "making + preparations" brush-off, and the apply/save YES-NO are ported; + accepting saves the game and opens the link menu, declining prints + "Please come again!". +- Cinnabar fossil deposit follows GiveFossilToCinnabarLab: a menu of + carried fossils (FossilsList order), SeesFossilText with a Yes/No + confirm, ComeAgainText on either cancel. +- Hall of Fame induction: each party mon's front sprite scrolls in from + the left at 4px/frame, matching HoFShowMonOrPlayer's .ScrollPic + front-pic phase (engine/movie/hall_of_fame.asm); the back-pic's + enlarged/blurred pre-wipe is a VRAM-scroll-register trick not + replicated in this sprite-based renderer. The finale + (HoFDisplayPlayerStats) shows trainer name, play time, money, POKéDEX + seen/owned, and Prof. Oak's rating text (engine/events/ + pokedex_rating.asm DexRatingsTable) from real save data. +- End credits + post-game reset (engine/movie/credits.asm, + scripts/HallOfFame.asm): screen-by-screen CreditsOrder pages (hlcoord + 9,6 + signed columns), FadeInCredits' 4x5-frame ramp, 90/110/120/140- + frame holds, DisplayCreditsMon's 27-frame 8px/frame silhouette wipe, + LoadCopyrightTiles' three-row block, THE END at (4,8). While THE END + is up the HoF script autosaves (wLastBlackoutMap := PALLET_TOWN; the + player is saved in the HALL_OF_FAME room), waits 600 frames, then A/B + triggers `jp Init`, the boot sequence replays into the title screen. +- Victory Road's boulder switches replicate the original's + ReplaceTileBlock data: 1F boulder at (17,13) -> block $1D at (4,6); + 2F boulders at (1,16)/(9,16) -> $15 at (3,4) and $1D at (11,7); 3F + boulder at (3,5) -> $1D at (3,5), and the (23,15) hole drops the + boulder to 2F (hide/show toggle). Barriers are re-applied from flags + on map entry, exactly like the originals' map-load scripts. +- Item balls, static legendary encounters and trainer rewards + (badges + gym TMs, the Silph Giovanni flag) are generic systems driven + by the extracted object args and a hand-ported reward table + (data/scripts/victories.lua). +- In-game trades use the real data/events/trades.asm table (species in, + species out, original nickname). + +## Safari game (engine/events/hidden_events/safari_game.asm + engine/battle) + +- ¥500 buys 30 SAFARI BALLs and 502 steps (scripts/SafariZoneGate.asm + sets `wSafariSteps = 502`); steps count down on the four outdoor zone + maps and hitting 0 (or throwing the last ball) ends the game at the + gate. +- Safari battles offer BALL / BAIT / ROCK / RUN; no player Pokémon + acts. The working catch rate starts at the species rate; BAIT halves + it and adds 1-5 to the bait factor (zeroing the escape factor); ROCK + doubles it (cap 255) and adds 1-5 to the escape factor (zeroing bait) + -- ItemUseBait/ItemUseRock in engine/items/item_effects.asm. +- Each turn one factor decays ("is eating!" / "is angry!"); when the + escape factor decays to 0 the catch rate resets to the species rate + (PrintSafariZoneBattleText, engine/battle/safari_zone.asm). +- Flee check (engine/battle/core.asm): `b = 2 * (speed % 256)`; the mon + always flees when speed > 127; while eating `b /= 4`, while angry + `b = min(255, 2b)`; it flees when `rand(0,255) < b`. +- The SAFARI BALL rolls the ULTRA_BALL rand range (0-150) in the Gen 1 + catch formula, against the BAIT/ROCK-modified rate. + +## Slot machines (engine/slots/slot_machine.asm) + +- The three reels are the extracted 18-symbol wheel sequences + (data/events/slot_machine_wheels.asm); bet 1 plays the middle row, + bet 2 adds top+bottom, bet 3 adds both diagonals. +- Payouts: 7-7-7 = 300, BAR = 100, CHERRY = 8, MOUSE/FISH/BIRD = 15 + (SlotRewardPointers). +- Per-wheel stop/slip rules ported exactly: wheel 1 spends up to 4 slip + charges, slipping past a centred CHERRY (in seven-and-bar mode it + always slips all 4 via pokered's `cp HIGH(SLOTS7)` bug); wheel 2 stops + as soon as wheels 1+2 line up any potential match (pairs checked b/b, + b/m, m/m, t/m, t/t) or, in seven-and-bar mode, on 7/BAR; wheel 3 rolls + past forbidden matches free and burns wSlotMachineRerollCounter + charges on winnable no-match spins, animated tile-by-tile. Luck flags + (SetFlags): seven-and-bar mode is sticky across spins; r==0 arms 60 + allow-matches charges; a BAR win clears flags; a 300 win zeroes the + counter and clears flags with probability 128/256; 8/15 wins burn one + charge. Lines are checked in asm order with the first match taken; + A-presses are ignored while a prior wheel's slip counter is nonzero. + Machine and COIN CASE texts are byte-identical + (_GameCorner*Text; AbleToPlaySlotsCheck's no-coins gate included). +- Flow brackets: PromptUserToPlaySlots "A slot machine! Want to play?" + (YesNoChoice) and MainSlotMachineLoop's "One more go?" (TwoOptionMenu); + the x3/x2/x1 coin menu (CoinMultiplierSlotMachineText) defaults its + cursor to x3, bet = 3 - menu item. Static frame: the real + SlotMachineMap (gfx/slots/slots.tilemap, 20x12 tile ids < $25) blitted + from red_slots_1.png, extracted as field.slotSymbols.tilemap + (tools/extract/gfx.py extract_slots). Win flash: + SlotMachine_CheckForMatches.flashScreenLoop flips rBGP (shade 3->2) b + times at 5 frames each, b = 20/8/4/2 for the 300/100/15/8 rewards + (SlotReward{300,100,8,15}Func). Payout drip: + SlotMachine_PayCoinsToPlayer credits one coin every 8 frames (4 for a + 7/BAR), SFX_SLOTS_REWARD per coin, rOBP0 symbol flicker every 5 coins. + +## Spinner arrow tiles (scripts/*.asm arrow movement tables) + +- Viridian Gym and Rocket Hideout B2F/B3F keep per-coordinate RLE + movement lists (map_coord_movement); each list executes backwards + from its terminator (DecodeArrowMovementRLE), sliding the player and + chaining onto further arrows. + +## Cries (data/pokemon/cries.asm, audio/engine_1.asm) + +- Each species = a base cry (one of 38 SFX_CryXX streams) + a frequency + modifier added to every note's frequency register + (Audio1_ApplyFrequencyModifier) + a tempo modifier + (`sfx tempo = $80 + length`, Audio1_SetSfxTempo). All 151 cries are + rendered offline with those modifiers applied and play on battle + entry and Pokédex pages. + +## Hidden events & facility puzzles + +- Card key doors (engine/events/card_key.asm): door tiles $18/$24 + (SILPH_CO_11F: $5e) replaced with block $0e ($03 on 11F). +- Vermilion trash cans + (engine/events/hidden_events/vermilion_gym_trash.asm): the first-lock + can re-rolls on every Vermilion City map load (VermilionCity_Script's + Random & $e, even cans) and after every failed second-can guess; the + second lock uses the GymTrashCans table verbatim, including the + underflow bug that can place it in can 0 regardless of adjacency; a + wrong pick resets EVENT_1ST_LOCK_OPENED and re-rolls immediately; only + SuccessText3 prints on completion; the gym door block at (2,2) is + $24 closed / $5 open (scripts/VermilionGym.asm). SuccessText1/ + SuccessText3/FailText play SFX_SWITCH/GO_INSIDE/DENIED from each + text's text_asm tail after the text prints (DisplayTextID's + WaitForTextScrollButtonPress then holds the box), so the port fires + them from an onDone on the TextBox, landing the beep as the box + closes rather than as it opens. +- Menu close-keys follow pokered's per-menu wMenuWatchedKeys mask, not + a single global rule: the shared Menu base (src/ui/Menu.lua) closes + on B only, and START-close is opt-in via opts.startCloses. Only the + start menu sets it, matching engine/menus/draw_start_menu.asm's + PAD_DOWN|PAD_UP|PAD_START|PAD_B|PAD_A; OptionsMenu also closes on + START via its own loop, matching engine/menus/main_menu.asm + DisplayOptionMenu's explicit B_PAD_B/B_PAD_START checks. Every other + menu (bag/PC item lists PAD_A|PAD_B|PAD_SELECT, party menu / + BUY-SELL-QUIT / USE-TOSS submenu / PC menus / Pokedex side menu + PAD_A|PAD_B) leaves PAD_START unwatched, so START does not close + them. START never replays SFX_PRESS_AB (HandleMenuInput_ beeps only + for the PAD_A|PAD_B branch). +- Old man tutorial hollow cursor: the item list is itself scripted in + pokered (DisplayListMenuID's old-man branch, home/list_menu.asm:65-91) + , no input is read; the filled '▶' hovers POKé BALL for 80 frames, + auto-presses A, then PlaceUnfilledArrowMenuCursor leaves the hollow + '▷' on that row until ItemUseBall tears the list down for the throw. + Ported via ListMenu's opts.script hook (src/ui/ListMenu.lua) and + BattleState:openOldManBag driving the same beats. The MissingNo./ + wGrassRate side effects of the OLD MAN name swap are not modeled, + see docs/gameboy-hardware-limitations.md. +- Gym statues (gym_statues.asm): plaque with the city/leader from each + gym's script; the player joins WINNING TRAINERS with the badge. +- Route 22 gate / Route 23 guards: real trigger rows, badge order + (EARTH down to CASCADE) and EVENT_PASSED_*_CHECK skip flags. +- Game Corner poster (scripts/GameCorner.asm): block (8,2) $2a -> $43 + on EVENT_FOUND_ROCKET_HIDEOUT. +- Seafoam Islands (scripts/SeafoamIslandsB3F/B4F.asm): reversed-RLE + current paths, Seafoam4HolesCoords boulder holes setting the + EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE pairs, the forced pool exit rows. +- Rock Tunnel darkness: wMapPalOffset = 6 on entry, cleared by Flash + (BOULDERBADGE) or leaving (home/overworld.asm). + +## Battle extras + +- GROWL/ROAR (GetMoveSound/IsCryMove, engine/battle/animations.asm + ~2196): the move's own MoveSoundTable tempo byte (Growl $c0, Roar + $40, both pitch $00) layers onto the cry via `Sound.playMoveCry`'s + `Source:setPitch(256/(128+tempoMod))`. Transform (engine/gfx/ + palettes.asm DeterminePaletteID, bit TRANSFORMED): the swapped-in pic + is tinted PAL_GRAYMON via `PaletteFX.monPal(data, species, + transformed)`, not the copied species' own palette, in + `BattleState:speciesSprite`. Growl (DoGrowlSpecialEffects, + animations.asm ~928): AnimPlayer's GROWL frame-block branch keeps a + `growlNoteTrail` snapshot so each block's emitted sprites include the + previous block's note copy alongside the current one (GROWL skips + AnimationCleanOAM between blocks per the `cp GROWL` check ~line 145); + ROAR is unaffected since the asm never applies this quirk to it. +- Master/Ultra ball tosses flicker the OBJ palette: DoBallTossSpecial + Effects (engine/battle/animations.asm:685) XORs rOBP0 with %00111100 + after every frame block while wCurItem <= ULTRA_BALL, so the 11 toss + blocks alternate the $F0/$CC shade maps starting normal; PlayAnimation + pushes/pops rOBP0 around each subanimation row, so the ambient + palette returns when the toss ends. GREAT/POKE/SAFARI balls never + flicker, and the toss arc always follows wCurItem via + TossBallAnimation, including the ghost-dodge throw. +- Anim-layer OBJ colorization is per 8x8 attribute cell: the SGB's + ATTR_BLK regions color the composited DMG picture per cell, not per + OAM entry, so an anim sprite overlapping a zone boundary takes each + cell's palette on the pixels inside it, AnimPlayer samples the zone + under every cell an 8x8 tile touches and repaints differing cells + through a cell-clipped scissor (aligned tiles stay one draw). +- Ball wobbles (ItemUseBall): Z = X*Y/255 + status2 with + Y = rate*100/ballFactor2; <10/<30/<70 -> 0/1/2 shakes, else 3, with + the matching ItemUseBallText01-04 lines. +- Trainer class AI (data/trainers/ai_pointers.asm + + engine/battle/trainer_ai.asm): per-class item/switch routines with + wAICount uses per Pokémon, ported to data/scripts/ai_classes.lua. +- Exp (engine/battle/experience.asm): baseExp*level/7 divided by the + participant count, x1.5 for trainers, x1.5 for traded mons; stat exp + in full to each participant. +- Move sounds: data/moves/sfx.asm (sound + pitch/tempo per move). The + pitch/tempo modifiers are applied at synthesis time + (Audio2_ApplyFrequencyModifier adds pitch to every frequency write; + Audio2_SetSfxTempo scales tone-channel note lengths, noise skips it), + 128 variant WAVs keyed "@" that Sound.playMove + selects, exact rather than a playback-rate approximation. Per-row + sounds fire as PlayAnimation does; GROWL/ROAR (IsCryMove) play the + attacker's cry. Hit sounds by effectiveness (Damage/Super/NotVery). +- Screen-effect animations (engine/battle/animations.asm + + engine/gfx/screen_effects.asm): every SE_* is implemented per-routine, + FlashScreen/FlashScreenLong (the FlashScreenLongSGB 12-entry table), + Dark/Light/DarkenMon/Reset palette ops (shade-map permutations of the + SGB zone palettes), all SlideMon variants, ShakeBackAndForth, + BoundUpAndDown, SquishMonPic, Minimize (real MinimizedMonSprite), + spiral/shoot-balls/water-droplets/leaves emitters compiled from the + asm trajectories, per-animation-id frame-block flashes (Explosion, + Rock Slide's rumbles, Blizzard's cadence...), AnimationWavyScreen with + true per-scanline offsets, PredefShakeScreenHorizontally/Vertically + and ShakeEnemyHUD. SE rows carry the faithful blocking durations. +- SGB battle colorization (SetPal_Battle, BlkPacket_Battle, + SetAnimationPalette): the battle screen is colorized by zone, player + HUD, enemy HUD, player mon + message box, enemy mon; trainer front + pics and the player/old-man back pics take PAL_MEWMON (both species + IDs are zero at the intro, so MonsterPalettes[0]); the ghost keeps the + disguised species' palette; attack animation sprites and thrown balls + are colored through the OBJ palettes (wAnimPalette $F0 on SGB, ambient + $E4, OBP1 $6C). Headless/no-shader environments fall back to the flat + pipeline. +- Mimic resolves mid-move (MimicEffect): accuracy first, then the + player's copy menu (enemy/link copy a random slot); the copy + overwrites only the slot's move ID, PP is shared with Mimic's slot, + and reverts on switch/battle end. +- Old man tutorial (DisplayBattleMenu's BATTLE_TYPE_OLD_MAN branch): the + real scripted cursor, ▶ beside FIGHT for 80 frames, beside ITEM for + 50, ITEM force-selected into the POKé BALL x50 list; the throw always + catches at full HP (item_effects.asm jumps straight to .captured, 3 + shakes, no party/dex add, no ball consumed); backing out of the bag + replays the script. The old man never attacks, the original tutorial + is menu navigation + a guaranteed catch, nothing more. + +## Link battles (lockstep) + +- Both sides simulate with a shared Park-Miller RNG stream (host deals + the seed), identical pack/unpack-clamped party copies, no badge + boosts, and a mirrored speed-tie roll (the guest inverts it); a + canonical host-side-first state hash is exchanged per turn and any + mismatch ends the match as a draw. + +## Music (audio/engine_1.asm) + +- Note duration: `frames = length * speed * tempo / 0x100` with + fractional carry, at 60 fps (Audio1_note_length / CalculateDelay). +- Frequency: `reg = pitches[note] asr (octave - 1)` (CalculateFrequency; + the octave byte stores `8 - octave`), `f = 131072/(2048 - reg)` for + squares, halved for channel 3. +- note_type volume/fade renders as an NRx2-style envelope (step every + `fade/64` s); duty_cycle maps to 12.5/25/50/75% pulse widths; + sound_call/sound_loop honor the engine's one-level call stack and + loop counters. + +## Text & font + +- The Pokédex height row uses the real ′/″ tiles: gfx/pokedex/pokedex.png + tiles 0/1 are patched over font-extra slots $60/$61 exactly as + engine/gfx/load_pokedex_tiles.asm loads them over vChars2 (they replace + glyphs charmap.asm marks unused); ASCII `"` aliases to the closing- + quote glyph $73 so stray hand-written quotes render. + +## Validation against the original + +- `tests/run_tests.lua` pins hand-checked values: L5 Bulbasaur 19 HP / + 9 Atk at 0 DVs, L100 Mewtwo 415 HP / 406 Spc at max DVs+statExp, + MEDIUM_SLOW(5) = 135, type chart spot checks, deterministic damage + rolls, Route 1 slot 1 = L3 Pidgey. +- The autopilot run reproduces the original's early flow on real map + data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by + walking off the mat, connection into Route 1 at matching x. diff --git a/docs/blue-version.md b/docs/blue-version.md new file mode 100644 index 00000000..313efb6a --- /dev/null +++ b/docs/blue-version.md @@ -0,0 +1,131 @@ +# Building a Blue Version of the port + +pokered builds Red and Blue from one source tree: the Makefile +assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and +every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)` +block. Our extraction pipeline resolves those conditionals the same +way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build +falls out of re-running the extractors. Only three things are +hand-ported on the Lua side and need swapping by hand. + +## What differs between Red and Blue + +| Where (pokered) | What | +| --- | --- | +| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) | +| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon | +| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon | +| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) | +| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border | +| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels | +| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) | + +## Step 1, flip the extraction define + +`tools/extract/util.py`: + +```python +ASM_DEFINES = {"_RED"} # -> {"_BLUE"} +``` + +Then regenerate everything (same as scripts/setup.sh does): + +```sh +cd tools +../.venv/bin/python3 build_data.py \ + --pokered /Users/bryanbassett/Documents/development/pokered \ + --out ../data/generated --assets ../assets/generated +``` + +This alone switches the wild encounters, preset names, SGB palettes +and credits text. **Caveat:** `parse_preset_names` sanity-checks in +`tools/extract/field.py:1193` expect "RED" in the player presets, +relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN +for the player and RED/ASH/JACK for the rival). + +`tools/extract/palettes.py` uses its own raw reader with hardcoded +`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port +it to `read_asm` so `ASM_DEFINES` covers it. + +## Step 2, title screen (hand-ported) + +`src/ui/TitleState.lua`: + +1. **Ribbon art.** The extractor writes + `assets/generated/title/red_version.png`; add `blue_version.png` + to `gfx.extract_title` in `tools/extract/gfx.py` (source: + `gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the + Red strip needs two quads (tiles 0–1 "Red", skip, tiles 5–9 + "Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip + prints its tiles contiguously (`db $61..$68`), so draw the whole + 64×8 image at px (56, 64) and verify with the title driver + screenshot. + +2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from + `data/pokemon/title_mons.asm`: + + ```lua + local CYCLE_SPECIES = { + "SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE", + "VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX", + "GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU", + } + ``` + +## Step 3, Game Corner prizes (hand-ported) + +`data/scripts/story3.lua` (~line 209) carries the Red prize tables. +Blue's values (`prizes.asm` + `prize_mon_levels.asm`): + +| Prize | Cost | Level | +| --- | --- | --- | +| ABRA | 120 | 6 | +| CLEFAIRY | 750 | 12 | +| NIDORINO | 1200 | 17 | +| PINSIR | 2500 | 20 | +| DRATINI | 4600 | 24 | +| PORYGON | 6500 | 18 | + +(TM prizes are identical in both versions.) + +## Step 4, verify + +```sh +luajit tests/run_tests.lua +``` + +Plus two spot checks: + +```sh +# every grass table must have exactly 10 slots (a conditional-parsing +# regression shows up as 19–20 slots) +luajit -e 'local e=dofile("data/generated/encounters.lua") +for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0 + and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end' + +# a Blue exclusive should now appear (and Growlithe should not) +grep -c VULPIX data/generated/encounters.lua +grep -c GROWLITHE data/generated/encounters.lua +``` + +Title screenshot: run the driver in +`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and +eyeball the ribbon, title mon, and copyright row. + +## What you get for free / what to skip + +- Free after re-extraction: encounters (incl. Super Rod groups), SGB + palettes, preset names, credits text, the gated sfx/animation + tweaks. +- Trades, gift Pokémon, story scripts, gym data: identical in Western + Red/Blue, nothing to touch. +- Save files: a Red save loads fine, but dex AREA nests and new + encounters will be Blue's. Trainer parties are identical. + +## Making it a runtime toggle instead + +If you want one build with both versions, extraction would need to +emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and +the three hand-ported spots would read a `save.version` or +`conf.lua` flag. That is a bigger change than the rebuild above, +the flip-and-regenerate route needs no engine changes at all. diff --git a/docs/extraction-notes.md b/docs/extraction-notes.md new file mode 100644 index 00000000..635a00a3 --- /dev/null +++ b/docs/extraction-notes.md @@ -0,0 +1,50 @@ +# ROM Extraction Notes + +There are two ROM-only extraction paths: + +- The packaged app uses `src/import/RomImporter.lua` and + `src/import/RomExtractor.lua` on first boot. +- Developers can run `tools/build_data.py --rom [--clean]` to generate + data in the source tree for audit and parity work. + +Both paths read only the supplied ROM and the checked-in +`tools/rom_manifest.json`. Neither invokes RGBDS, Git, or a disassembly. + +## Validation + +Only the canonical US Pokemon Red ROM is supported. SHA-1 is checked before +any cached output is removed or written. + +## Decoded Data + +| Area | ROM data | +| --- | --- | +| world | map headers, block maps, connections, warps, signs, objects | +| tiles | tileset graphics, blocksets, collision, door and warp tile lists | +| text | 2,584 text command streams and RAM/number substitutions | +| Pokemon | names, stats, evolutions, learnsets, Dex data, compressed pictures | +| battle | moves, detailed animations, OAM frames/tiles, effects, type chart, palettes, trainer parties/AI/pictures | +| inventory | item names, prices, key-item flags, TM/HM data | +| encounters | grass and water wild tables | +| UI | fonts, icons, title/intro, trainer card, town map, slots, field effects | +| audio | music, SFX and cry headers, channel programs, wave instruments | + +The Python and Lua picture decompressors implement the Gen 1 `pic` format. +Graphics are converted to RGBA PNGs. OAM artwork uses transparent color 0; +battle pictures use edge-connected white matting so white interior details +remain visible. + +The in-app importer stores three audio ROM banks as a 48 KiB +`programs.bin`. `src/core/ChipAudio.lua` interprets the channel bytecode and +synthesizes music as a queueable stream; SFX and cries are synthesized on +demand. This avoids shipping or generating a large WAV/OGG tree. + +## Metadata Boundary + +Names, dimensions, enum ordering, Lua script hooks, and hand-ported field +behavior do not survive compilation in a form the Lua runtime can infer. +Those relationships are bundled in `rom_manifest.json`. The manifest stores +no dialogue strings, images, audio samples, or ROM bytes. + +`tools/make_rom_manifest.py` and `tools/verify_rom_data.py` are developer audit +tools. They are not used by the packaged game. diff --git a/docs/gameboy-hardware-limitations.md b/docs/gameboy-hardware-limitations.md new file mode 100644 index 00000000..c5da32f6 --- /dev/null +++ b/docs/gameboy-hardware-limitations.md @@ -0,0 +1,52 @@ +# Game Boy Hardware Limitations Carried Into This Port + +This is a 1:1 parity port of Pokémon Red onto a modern engine (LÖVE2D), so a +lot of the original game's design isn't "design" at all, it's a direct +consequence of what the actual Game Boy hardware could physically do. None +of these constraints apply to a Lua table or a 2026 GPU. They're kept here +purely for faithfulness to the original, and documented below so it's clear +which limits are load-bearing history rather than intentional choices for +this port. + +## Kept faithfully (gameplay-visible limits reproduced on purpose) + +| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here | +|---|---|---|---|---| +| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) | +| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) | +| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` | +| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` | +| 5 | Screen resolution / tile grid | 160×144 px, 8×8 tiles, 20×18 visible tiles | The Game Boy PPU's actual pixel and tile-map dimensions | `src/render/Renderer.lua:13-14`, `src/render/TileRenderer.lua:64`, `src/render/BattleTransition.lua:25` | +| 6 | Name length | Nickname 10 chars, trainer/rival name 7 chars | Fixed-width `wPlayerName` and nickname byte buffers in SRAM | `src/ui/OakSpeech.lua:91,108`, `src/battle/BattleState.lua:2240`, `src/ui/NamingScreen.lua:50` | +| 7 | Text box size / word wrap | 20×6 tile dialogue window, 18-column wrap | Text rendered directly into the tile-map grid | `src/render/TextBox.lua:14,17` | +| 8 | Text print speed | 1/3/5-frame character delay | Text was drawn character-by-character into VRAM on a fixed 60Hz frame budget | `src/render/TextBox.lua:133-136`, `src/core/SaveData.lua` (`textSpeed = 3` default) | +| 9 | Audio channel behavior | 4 channels (2 pulse, 1 wave, 1 noise); fanfares "steal" the music's tone channels | The GB APU only has 4 physical sound channels | `src/core/Sound.lua:14-21`, `docs/behavior-porting-notes.md:225-235` | +| 10 | Stat/damage byte-overflow bugs | Atk/Def quartered when either exceeds 255; Focus Energy crit bug; stat-exp capped at 255 pre-scale | Original math ran on 8-bit registers and overflowed/wrapped exactly this way, these are *bugs*, kept for authenticity | `src/battle/Damage.lua:19-25,143-148`, `src/pokemon/Stats.lua:25-27` (toggleable via `gen1_faithful` ruleset) | +| 11 | Fixed 60Hz update step | `STEP = 1/60` | The Game Boy's actual refresh rate | `src/core/FixedStep.lua` | + +## Explicitly NOT carried over (the hardware cause disappeared, so the effect was dropped) + +| # | Mechanic | Original GB constraint | Status here | +|---|---|---|---| +| 1 | OAM sprite limits | Max 40 sprites on screen, max 10 per scanline (causes the classic flicker) | Not modeled, LÖVE draws every sprite unconditionally, no scanline budget or cycling exists | +| 2 | Money cap of 999999 | Money was packed as 3 BCD bytes in SRAM, capping at 999,999 | Not enforced, money is only floored at 0, can grow unbounded | +| 3 | DMA transfer / VBlank timing | Sprite/tile updates had to be batched into the VBlank window via DMA | Not applicable, no equivalent constraint in a modern renderer | +| 4 | SRAM save layout | Save data was a precise byte-for-byte struct fit to a small battery-backed SRAM chip | Save file is a plain serialized Lua table (`src/core/SaveData.lua`), not an SRAM layout | +| 5 | Weak/deterministic RNG | The GB's RNG was driven by timer/divider registers, not a real PRNG | Replaced with `love.math.random` / `math.random`, only the *value ranges/thresholds* the original produced are kept. Exception: link battles use a deterministic Park-Miller LCG (`src/link/LinkBattle.lua:22-33`) so both sides can reproduce identical rolls, that's a design choice, not a GB replication | +| 6 | VRAM tile budget | 256 tiles per bank, 2 VRAM banks total | Not applicable, generated tile sheets aren't memory-budget constrained | +| 7 | Old man glitch (MissingNo. / 'M, +128 item duplication, Hall of Fame corruption) | The catch tutorial stashes the 11-byte player name over `wGrassRate`/`wGrassMons` (core.asm:2024-2037) and restores it from there after the throw (item_effects.asm:159-164), leaving name bytes in the grass table; `LoadWildData` skips rewriting `wGrassMons` on maps with grass rate 0 (wild_mons.asm:13-16), so Cinnabar/Route 21 shore tiles read name characters as level/species pairs, out-of-range species IDs render garbage base-stat/sprite memory as MissingNo./'M, the dex-#0 seen-flag write lands 255 bits past `wPokedexSeen` onto bag slot 6's quantity byte (+128 items), and the oversized glitch sprite overflows the decompression buffer into Hall of Fame SRAM | Not modeled, encounters are a pure per-map data lookup (`src/world/Encounter.lua` + `data/generated/encounters.lua`) with no stale copied buffer, the species table is closed (no out-of-range reads to render), and dex flags / bag / HoF data are separate Lua tables with no address adjacency; the tutorial's OLD MAN name swap is kept only in its visible text (`BattleState:oldManThrow`) | + +## Notes + +- PC Box **overflow handling** was deliberately changed even though the + 20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards + or blocks the deposit," this port spills into the next box with room. +- Several battle-related overflow "bugs" (byte overflow, Focus Energy) are + gated behind a `gen1_faithful` vs `modern_clean` ruleset toggle in + `src/battle/rulesets/`, so the faithful-but-buggy behavior can be turned + off without losing the option to replay it exactly as it shipped in 1996. +- Hardware-terminology comments referencing OAM, VRAM, DMA, BCD, SGB + colorization, etc. are scattered across ~14 files for context even where + the constraint itself isn't enforced (e.g. `src/battle/AnimPlayer.lua:310-312` + documents OAM sprite-hiding coordinate quirks used for animation + correctness, without implementing the underlying 40-sprite cap). diff --git a/docs/known-differences.md b/docs/known-differences.md new file mode 100644 index 00000000..92a2fff8 --- /dev/null +++ b/docs/known-differences.md @@ -0,0 +1,9 @@ +# Known differences from the original game + +Only genuine remaining divergences live here: behavior that is still +**missing, wrong, or approximated for convenience** and would need more +work for true parity. Faithfully-ported behavior is documented in +docs/behavior-porting-notes.md; deliberate additions beyond the original +are in docs/new-features.md. + +None currently. diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 00000000..60b366c6 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,58 @@ +# Native modding + +The game has a built-in Lua mod runtime. Mods are installed under the LÖVE +save directory in `mods//` and are loaded after the verified ROM data has +been imported but before the title screen is created. + +## Minimal mod + +```text +mods/example_mod/ +├── manifest.json +└── main.lua +``` + +`manifest.json`: + +```json +{ + "id": "example_mod", + "name": "Example Mod", + "version": "1.0.0", + "entry": "main.lua", + "priority": 0, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [] +} +``` + +`main.lua`: + +```lua +return function(mod) + mod.log:info("hello from a native mod") + + mod.content.pokemon:override("PIKACHU", { + name = "PIKACHU", + types = { "ELECTRIC" }, + base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 }, + }) + + mod.events:on("battle.start", function(context) + context.mod_message = "A native mod changed this battle." + end) +end +``` + +Mods should use registries and events instead of requiring private engine +modules. Registries currently cover Pokémon, moves, items, maps, tilesets, +encounters, trainers, sprites, music, audio, text, scripts, and UI. + +Enablement is stored in the normal persistent `options.lua` file alongside +audio, display, and battle settings, so starting a new game does not disable +the selected mods. Changes take effect after restarting the game. + +The loader deliberately does not import or execute arbitrary ROM-hack patches. +The supported content source remains the verified base Pokémon Red ROM plus +native mods. diff --git a/docs/new-features.md b/docs/new-features.md new file mode 100644 index 00000000..6f6f5436 --- /dev/null +++ b/docs/new-features.md @@ -0,0 +1,137 @@ +# New features (deliberate additions beyond the original) + +Intentional enhancements this port adds on top of faithful Pokémon Red +behavior. They have no Game Boy equivalent and are kept by design. +Genuine divergences from the original (things still missing, wrong, or +approximated) live in docs/known-differences.md; faithfully-ported +behavior is in docs/behavior-porting-notes.md. + +## Survey zoom + +The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world +pixel (full survey) and 2× the window fit scale (close-up), in crisp +integer steps. This has no Game Boy equivalent: + +- Connected maps render their full bodies, and their NPCs appear as + visual-only "ghosts", they wander but have no sight lines, triggers, + dialogue, or collision until the map is actually entered. +- Menus, text boxes, and battles draw at normal scale on top of the + zoomed world. Zoom input is ignored while a script, menu, or battle is + active; the zoom level persists across warps and is never saved. +- Beyond the border ring the border block repeats indefinitely (interiors + stay black, seaside towns stay water, except OVERWORLD-tileset maps, + whose beyond-edge space fills with the solid tree wall instead of the + per-map border block), and each visible map area is colorized with its + own SGB palette (the original recolored the whole screen per map). +- Neighbor maps load two connection hops out so corner-adjacent maps + don't pop in and out, and ghost NPCs share instances with the real ones + so their wander positions persist across seamless connection crossings + (a warp or fresh map entry still respawns everything at its script + position, like the original's per-entry sprite init). + +## Tilt mode + +The `3` key (and the Options menu TILT row) cycles a visual-only perspective +tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D / +diorama look. Like survey zoom this is purely presentational and has no +Game Boy equivalent: + +- The entire map tilts as one rigid ground plane, paths, grass, water, + floors, and every background-tile structure (buildings, trees, fences, + signs; in Gen 1 these are baked into the tile layer, not sprites), so + rows above the player recede and rows below come toward the viewer. Only + things that actually *stand* on the ground draw as upright billboards, + unscaled and pixel-identical to flat mode: the player, NPCs, item balls, + and the screen-anchored FX attached to them (heal machine glow, emote + bubbles, the fishing rod, the FLY bird). An earlier revision tried + billboarding buildings/trees/signs too (cutting them out of the ground + per hand-curated per-tileset tables); that chased an endless tail of + special cases, dense tree canopy, fences fused into grass, building + facades with their own baked-in fake perspective, because Gen 1's art + was never drawn with a clean seam between ground and standing scenery. It + wasn't merged; tilting everything but the characters as one plane is the + simpler, shipped tradeoff (buildings recede/foreshorten with the ground + like a photo of a diorama, rather than standing fully upright next to + a full-height character). +- Cycling tweens the angle between levels over ~0.25s rather than snapping; + with tilt fully off the world pass drops back onto the flat blit path, so + flat rendering stays pixel-identical to tilt-off and off costs nothing. +- Tilt input is gated exactly like survey zoom, honored only while + free-roaming, ignored while a script, menu, or battle is active, and it + composes with survey zoom (the zoom scale feeds the projection). The tilt + level is persisted in `save.options.tilt` (default OFF). +- It applies everywhere the overworld draws, interiors and caves included. + Menus, text boxes, and battles render flat on top, unaffected, and the + infinite beyond-the-border-ring fill stays flat by design. +- Collision, movement, sight lines, triggers, encounters, and scripts are + untouched; nothing about the tilt reaches gameplay. + +## Colors mode + +The `2` key (and the Options menu COLORS row) cycles the global shade-remap +display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**: + +- **GBC** (default): current SGB / GBC zone palettes. +- **OG**: force the four DMG grays (colorization off). +- **OG INV**: inverted DMG grays. +- **GBC INV**: each SGB zone palette with shade order reversed. +- **CLASSIC**: original Game Boy pea-soup greens + (`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`). + +The transform is applied centrally in `PaletteFX.sendColors`, so it covers +overworld, menus, battles, and tilt upright billboards. Persisted as +`save.options.colors`. + +## GBC FX + +The `5` key (and the Options menu GBC FX row) cycles a "played on real +unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The +levels are a cumulative ladder: + +- **1**: reflective-screen backing transparency. +- **2**: + LCD pixel grid. +- **3**: + pixel drop shadows. +- **4**: + sunlight glare and rainbow shimmer with a drifting light. + +It runs as a final present pass after world + UI composite in +`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader +([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)). +Default OFF; persisted as `save.options.gbcfx`. + +## Peer-to-peer link play (lua-enet) + +Trades and link battles connect two copies of the game directly over +lua-enet (ENet ships inside LÖVE, nothing to install, no server to run) +on a reliable-ordered channel, replacing the original standalone Python +room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the +host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A +GAME enters it. Closing performs a graceful ENet disconnect so the final +confirm/bye always lands; a vanished peer exits with "The link was +broken." Internet play needs a forwarded UDP port or a VPN (deliberate +tradeoff vs. the relay). Headless tests drive the protocol over an +in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file +also exercises real UDP pairing. + +## Custom boot text + +The boot sequence replaces the Nintendo / GAME FREAK identifiers with +"bois club" / "bryanthaboi", a deliberate branding customization. The +rest of the boot beats (copyright splash, "presents" shooting-star, the +Nidorino-vs-Gengar attract scene) mirror the original. + + +## Custom Options + +Options persist in a standalone `options.lua` (separate from the game +progress `save.lua`), so audio/display/battle preferences survive New Game +and aren't wiped when a save slot is cleared. Changing a row in the Options +menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also +flushes the live options. Old saves that still embed an `options` table are +migrated once into `options.lua` on load. + +- Music / SFX volume +- Music Filter +- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) +- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2` +- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming +- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` \ No newline at end of file diff --git a/docs/required-to-function.md b/docs/required-to-function.md new file mode 100644 index 00000000..b6b6ea2a --- /dev/null +++ b/docs/required-to-function.md @@ -0,0 +1,64 @@ +# What This Port Requires + +The packaged desktop app requires one user-supplied input on first boot: a +canonical 1 MiB US Pokemon Red ROM. + +The importer verifies SHA-1 +`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual +Console releases, and Pokemon Blue are rejected rather than decoded with +incorrect addresses. + +After verification, the app generates its private cache in the LÖVE save +directory. It does not keep a copy of the ROM. Later boots use the cache. +Python and Pillow are not required by the packaged app. + +## Bundled Metadata + +Assembly removes high-level names and some relationships that the Lua port +needs. `tools/rom_manifest.json` therefore contains: + +- the 3,268 ROM symbol addresses actually read by the extractor +- symbolic IDs and ordering for maps, species, moves, items, and trainers +- source-erased dimensions, image names, and map object integration names +- hand-ported field/script integration tables +- text labels and runtime substitution markers, but no dialogue payload +- music, sound-effect, and cry header names and addresses + +The manifest contains no ROM bytes, graphics, dialogue, audio samples, or +complete symbol file. Dialogue, map blocks, encounters, stats, names, parties, +palettes, artwork, and audio channel programs are read from the user's ROM. + +## Generated Output + +First boot writes: + +- `data/generated/`: constants, maps, tilesets, text and pointer tables, + Pokemon, moves, items, trainers, encounters, field data, palettes, and font + mappings, detailed battle animation programs, plus compact audio metadata +- `assets/generated/`: 495 PNGs covering maps, overworld sprites, fonts, + Pokemon/trainer pictures, title and intro art, menus, field effects, and + battle animation tiles +- `assets/generated/audio/programs.bin`: three 16 KiB ROM banks containing + the music, sound-effect, cry, and waveform programs used by live synthesis + +Map behavior remains hand-ported under `data/scripts/`. + +## Developer Builder + +The optional source-tree builder provides a repeatable audit path: + +```sh +python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean +``` + +That path requires Python 3.10+ and Pillow. It is not part of a packaged +game's first boot. + +## Not Required + +- a `pret/pokered` checkout +- the `symbols` branch or a `.sym` file +- Git +- RGBDS +- a separately compiled ROM +- Python or Pillow when running the packaged app diff --git a/main.lua b/main.lua new file mode 100644 index 00000000..2b14b04d --- /dev/null +++ b/main.lua @@ -0,0 +1,201 @@ +-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private +-- game-data cache from a user-provided ROM on first boot. +-- +-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save +-- editor tool (tools/save-editor/) instead of the game. + +local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true + +local Game, EditorApp, Importer + +local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) +local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a + -- coroutine that receives `Game` and yields once per + -- frame; used headless (xvfb) for scripted screenshots + +local function bootGame() + Game = require("src.core.Game") + Game:load() + if os.getenv("POKEPORT_AUTOPILOT") then + autopilot = require("tests.autopilot") + end + local driverPath = os.getenv("POKEPORT_DRIVER") + if driverPath then + local fn = assert(loadfile(driverPath))() + driverCo = coroutine.create(fn) + end +end + +function love.load(args) + local savePath + for i, a in ipairs(args or {}) do + if a == "--editor" then + editorMode = true + elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then + savePath = args[i + 1] + end + end + love.graphics.setDefaultFilter("nearest", "nearest") + + if editorMode then + package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;" + .. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;" + .. package.path + EditorApp = require("App") + EditorApp.load(savePath) + return + end + + local RomImporter = require("src.import.RomImporter") + if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then + Importer = RomImporter.new(function() + if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then + love.event.quit() + return + end + Importer = nil + bootGame() + end) + local importPath = os.getenv("POKEPORT_IMPORT_ROM") + if importPath then Importer:startPath(importPath) end + return + end + bootGame() +end + +function love.update(dt) + if editorMode then return EditorApp.update(dt) end + if Importer then return Importer:update(dt) end + + if autopilot then + autopilot.update() + Game:update(1 / 60) -- deterministic stepping for the autopilot + return + end + if driverCo then + local ok, err = coroutine.resume(driverCo, Game) + if not ok then + print("driver error: " .. tostring(err)) + love.event.quit(1) + return + end + if coroutine.status(driverCo) == "dead" then + love.event.quit() + return + end + Game:update(1 / 60) + return + end + Game:update(dt) +end + +function love.draw() + if editorMode then return EditorApp.draw() end + if Importer then return Importer:draw() end + + Game:draw() + -- frame capture requested by a driver + if Game.capturePath then + local path = Game.capturePath + Game.capturePath = nil + love.graphics.captureScreenshot(function(imagedata) + local fd = imagedata:encode("png") + local f = io.open(path, "wb") + if f then + f:write(fd:getString()) + f:close() + end + end) + end +end + +function love.keypressed(key, scancode, isrepeat) + if editorMode then return EditorApp.keypressed(key) end + if Importer then return Importer:keypressed(key) end + Game:keypressed(key) +end + +function love.keyreleased(key) + if editorMode then return end + if Importer then return end + Game:keyreleased(key) +end + +function love.gamepadpressed(joystick, button) + if editorMode then return end + if Importer then return end + Game:gamepadpressed(joystick, button) +end + +function love.gamepadreleased(joystick, button) + if editorMode then return end + if Importer then return end + Game:gamepadreleased(joystick, button) +end + +function love.gamepadaxis(joystick, axis, value) + if editorMode then return end + if Importer then return end + Game:gamepadaxis(joystick, axis, value) +end + +function love.touchpressed(id, x, y, dx, dy, pressure) + if editorMode then return end + if Importer then return Importer:mousepressed(x, y, 1) end + Game:touchpressed(id, x, y) +end + +function love.touchmoved(id, x, y, dx, dy, pressure) + if editorMode then return end + if Importer then return end + Game:touchmoved(id, x, y) +end + +function love.touchreleased(id, x, y, dx, dy, pressure) + if editorMode then return end + if Importer then return end + Game:touchreleased(id, x, y) +end + +function love.wheelmoved(x, y) + if editorMode then + if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end + return + end + if Importer then return end + Game:wheelmoved(x, y) +end + +function love.mousepressed(x, y, button) + if Importer then return Importer:mousepressed(x, y, button) end + if editorMode and EditorApp.mousepressed then + return EditorApp.mousepressed(x, y, button) + end +end + +function love.mousereleased(x, y, button) + if Importer then return end + if editorMode and EditorApp.mousereleased then + return EditorApp.mousereleased(x, y, button) + end +end + +function love.textinput(text) + if Importer then return end + if editorMode and EditorApp.textinput then + return EditorApp.textinput(text) + end +end + +function love.quit() + if editorMode and EditorApp.quit then + return EditorApp.quit() -- return true to abort quit + end +end + +function love.filedropped(file) + if editorMode and EditorApp and EditorApp.filedropped then + return EditorApp.filedropped(file) + end + if Importer then Importer:filedropped(file) end +end diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 new file mode 100755 index 00000000..d3d2e523 --- /dev/null +++ b/scripts/bootstrap.ps1 @@ -0,0 +1,108 @@ +# Windows double-click bootstrap for the LÖVE2D Pokémon Red port. +# Launched by Play-Windows.bat. Prompts to install any missing tools +# (Python 3 and LÖVE via winget), runs first-time setup, then +# starts the game. Later runs launch the game straight away. + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot + +function Say($msg) { Write-Host "==> $msg" -ForegroundColor Green } +function Warn($msg) { Write-Host " !! $msg" -ForegroundColor Yellow } +function Err($msg) { Write-Host "error: $msg" -ForegroundColor Red } + +function Pause-Exit([int]$code = 0) { + Write-Host '' + Read-Host 'Press Enter to close this window' | Out-Null + exit $code +} + +function Ask($question) { # yes by default + $a = Read-Host "$question [Y/n]" + return ($a -notmatch '^(n|no)$') +} + +# winget installs update the registry PATH but not this process's copy; +# re-read it so freshly installed tools are usable without a new window. +function Refresh-Path { + $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machine;$user" +} + +function Find-Python { + if (Get-Command py -ErrorAction SilentlyContinue) { + try { if ((& py -3 --version 2>$null) -match '^Python 3') { return $true } } catch {} + } + if (Get-Command python -ErrorAction SilentlyContinue) { + try { if ((& python --version 2>$null) -match '^Python 3') { return $true } } catch {} + } + return $false +} + +function Find-Love { + foreach ($name in 'lovec', 'love') { + if (Get-Command $name -ErrorAction SilentlyContinue) { return $true } + } + foreach ($d in @("$env:ProgramFiles\LOVE", "${env:ProgramFiles(x86)}\LOVE", "$env:LOCALAPPDATA\Programs\LOVE")) { + if ($d -and (Test-Path (Join-Path $d 'love.exe'))) { return $true } + } + return $false +} + +Write-Host '' +Write-Host ' Pokemon Red - LOVE2D port' -ForegroundColor Cyan +Write-Host '' + +# ---------------------------------------------------------------- fast path +if ((Test-Path (Join-Path $Root 'data\generated\maps.lua')) -and (Find-Love)) { + Say 'already set up - launching the game' + & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\run.ps1') + if ($LASTEXITCODE -ne 0) { Err 'the game failed to start'; Pause-Exit 1 } + exit 0 +} + +Say 'first-time setup' + +# ------------------------------------------------------------------- winget +if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Err 'winget (Windows package manager) is not available.' + Warn 'Install "App Installer" from the Microsoft Store, then run this again.' + Start-Process 'ms-windows-store://pdp/?ProductId=9NBLGGH4NNS1' -ErrorAction SilentlyContinue + Pause-Exit 1 +} + +# ------------------------------------------------------------------- python +if (-not (Find-Python)) { + Warn 'Python 3 is missing (needed to build the game data)' + if (Ask 'Install Python 3 now via winget?') { + winget install --exact --id Python.Python.3.12 --accept-source-agreements --accept-package-agreements + Refresh-Path + if (-not (Find-Python)) { + Err 'Python still not found after install - close this window and double-click again' + Pause-Exit 1 + } + } else { Err 'cannot continue without Python 3'; Pause-Exit 1 } +} + +# --------------------------------------------------------------------- LOVE +if (-not (Find-Love)) { + Warn 'LOVE (the game engine) is missing' + if (Ask 'Install LOVE now via winget?') { + winget install --exact --id Love2d.Love2d --accept-source-agreements --accept-package-agreements + Refresh-Path + if (-not (Find-Love)) { + Err 'LOVE still not found after install - close this window and double-click again' + Pause-Exit 1 + } + } else { Err 'cannot continue without LOVE'; Pause-Exit 1 } +} + +# -------------------------------------------------------------------- build +Write-Host '' +& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\setup.ps1') +if ($LASTEXITCODE -ne 0) { Err 'setup failed - see the messages above'; Pause-Exit 1 } + +Say 'setup done - launching the game' +& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\run.ps1') +if ($LASTEXITCODE -ne 0) { Err 'the game failed to start'; Pause-Exit 1 } +exit 0 diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..3b34bb31 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Packages the LÖVE2D Pokémon Red port into distributable macOS and +# Windows builds. Runs entirely on macOS (no cross-compiling needed, +# the Windows build reuses LÖVE's prebuilt win64 binaries). +# +# Usage: scripts/build.sh [mac|win|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."] +# [--notary-profile NAME] [--no-notarize] +# [--release] # android/ios: release config instead of debug +# +# Output: dist/mac/PokemonRed-macos.zip +# dist/win/PokemonRed-win64.zip +# dist/android/{debug,release}/*.apk (full gradle output stays under +# mobile/android/app/build/outputs/apk/embedNoRecord/) +# dist/ios/-/PokemonRed.app (full xcodebuild output stays +# under mobile/ios/build/Build/Products/) + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HERE="$ROOT/.bazinga" +CACHE="$HERE/cache" +WORK="$HERE/work" +DIST="$ROOT/dist" +ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist" + +APP_NAME="PokemonRed" +BUNDLE_ID="com.theboisclub.pokemonred" +LOVE_VERSION="11.5" +VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)" +IDENTITY="" +TARGET="all" +NOTARY_PROFILE="notary-profile" +NOTARIZE=true +ANDROID_RELEASE=false +IOS_RELEASE=false + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + mac|win|android|ios|all) TARGET="$1" ;; + --version) VERSION="$2"; shift ;; + --identity) IDENTITY="$2"; shift ;; + --notary-profile) NOTARY_PROFILE="$2"; shift ;; + --no-notarize) NOTARIZE=false ;; + --release) ANDROID_RELEASE=true; IOS_RELEASE=true ;; + *) fail "unknown argument: $1" ;; + esac + shift +done + +mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" + +# --------------------------------------------------------------- game.love +say "packing game.love" +LOVE_FILE="$WORK/game.love" +rm -f "$LOVE_FILE" +(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ + main.lua conf.lua src data assets tools/rom_manifest.json \ + -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') +if unzip -Z1 "$LOVE_FILE" \ + | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then + fail "game.love unexpectedly contains generated ROM data" +fi +say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" + +# --------------------------------------------------------------- macOS +build_mac() { + say "building macOS app" + local love_app="${LOVE_APP:-/Applications/love.app}" + [ -d "$love_app" ] || fail "LÖVE.app not found at $love_app (install it or set LOVE_APP=/path/to/love.app)" + + local out_app="$WORK/$APP_NAME.app" + rm -rf "$out_app" + cp -R "$love_app" "$out_app" + + # drop any bundled placeholder .love and fuse ours in + find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete + cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" + + local plist="$out_app/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist" + + if [ -f "$ROOT/assets/icon.icns" ]; then + cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns" + fi + + local id="$IDENTITY" + if [ -z "$id" ]; then + id="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/^[^"]*"(.*)"$/\1/' || true)" + fi + if [ -n "$id" ]; then + say "codesigning with: $id" + codesign --deep --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" --sign "$id" "$out_app" + codesign --verify --deep --strict --verbose=2 "$out_app" + else + warn "no 'Developer ID Application' identity found, shipping unsigned." + warn "install your cert in Keychain Access, then re-run (or pass --identity \"Developer ID Application: Name (TEAMID)\")." + warn "unsigned builds will be Gatekeeper-blocked on other Macs; notarize with 'xcrun notarytool submit' once signed." + NOTARIZE=false + fi + + if [ "$NOTARIZE" = true ]; then + if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1; then + warn "keychain profile '$NOTARY_PROFILE' not found/working, skipping notarization." + warn "set it up with: xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id ... --team-id ... --password ..." + else + local notarize_zip="$WORK/$APP_NAME-notarize.zip" + rm -f "$notarize_zip" + (cd "$WORK" && ditto -c -k --keepParent "$APP_NAME.app" "$notarize_zip") + say "submitting to Apple notary service (this can take a few minutes)" + xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait + say "stapling notarization ticket" + xcrun stapler staple "$out_app" + rm -f "$notarize_zip" + fi + fi + + local zip_out="$DIST/mac/$APP_NAME-macos.zip" + rm -f "$zip_out" + (cd "$WORK" && ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$zip_out") + say "macOS build: $zip_out" +} + +# --------------------------------------------------------------- Windows +build_win() { + say "building Windows (win64) app" + local zip_name="love-$LOVE_VERSION-win64.zip" + local love_zip="$CACHE/$zip_name" + if [ ! -f "$love_zip" ]; then + say "downloading LÖVE $LOVE_VERSION win64 binaries" + curl -fL --progress-bar \ + "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \ + -o "$love_zip" || fail "download failed, check LOVE_VERSION or your network" + fi + + local extract_dir="$WORK/love-win64" + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + unzip -q "$love_zip" -d "$extract_dir" + local love_dir + love_dir="$(find "$extract_dir" -maxdepth 1 -mindepth 1 -type d | head -1)" + + local out_dir="$WORK/$APP_NAME-win64" + rm -rf "$out_dir" + mkdir -p "$out_dir" + cp "$love_dir"/*.dll "$out_dir"/ + cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true + + cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe" + + local zip_out="$DIST/win/$APP_NAME-win64.zip" + rm -f "$zip_out" + (cd "$WORK" && zip -q -9 -r "$zip_out" "$APP_NAME-win64") + say "Windows build: $zip_out" +} + +# --------------------------------------------------------------- Android +build_android() { + say "building Android (delegating to scripts/build_android.sh)" + local args=() + if [ "$ANDROID_RELEASE" = true ]; then + args+=(--release) + fi + "$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"} +} + +# --------------------------------------------------------------- iOS +build_ios() { + say "building iOS (delegating to scripts/build_ios.sh)" + local args=() + if [ "$IOS_RELEASE" = true ]; then + args+=(--release) + fi + "$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"} +} + +case "$TARGET" in + mac) build_mac ;; + win) build_win ;; + android) build_android ;; + ios) build_ios ;; + all) build_mac; build_win ;; +esac + +case "$TARGET" in + android) say "done. See $DIST/android/" ;; + ios) say "done. See $DIST/ios/" ;; + *) say "done. Artifacts in $DIST" ;; +esac diff --git a/scripts/build_android.sh b/scripts/build_android.sh new file mode 100755 index 00000000..fd13c088 --- /dev/null +++ b/scripts/build_android.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. +# +# Usage: scripts/build_android.sh [--release] [--package-only] +# +# (default) assembleEmbedNoRecordDebug (debug keystore) +# --release assembleEmbedNoRecordRelease (requires out-of-band signing) +# --package-only zip game.love + apply branding; skip gradle +# +# Prerequisites: +# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) +# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653) +# - JDK 17 +# +# Output (after gradle): +# dist/android/{debug,release}/*.apk (convenience copy) +# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk +# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ANDROID_DIR="$ROOT/mobile/android" +EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets" +LOVE_FILE="$EMBED_ASSETS/game.love" +DIST="$ROOT/dist/android" +APP_NAME="Pokemon Red" +APPLICATION_ID="com.theboisclub.pokemonred" +LOVE_ANDROID_VERSION="11.5a" +NDK_VERSION="25.2.9519653" + +RELEASE=false +PACKAGE_ONLY=false + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --release) RELEASE=true ;; + --package-only) PACKAGE_ONLY=true ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) fail "unknown argument: $1 (try --release or --package-only)" ;; + esac + shift +done + +# --------------------------------------------------------------- preconditions +if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then + fail "love-android not found at mobile/android/. + The love-android $LOVE_ANDROID_VERSION tree is vendored in this repo, your checkout + looks incomplete. Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md." +fi + +if [ ! -d "$ANDROID_DIR/love/src/jni/love/src" ]; then + fail "liblove sources missing under mobile/android/love/src/jni/love/. + They are vendored in this repo, your checkout looks incomplete. + Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md." +fi + +# --------------------------------------------------------------- branding +# love-android 11.5+ reads app id / name / orientation from gradle.properties. +# Manifest still gets permission trims. Re-applied every build so refreshing +# the vendored love-android tree does not lose project settings. +apply_android_branding() { + local props="$ANDROID_DIR/gradle.properties" + local manifest="$ANDROID_DIR/app/src/main/AndroidManifest.xml" + [ -f "$props" ] || fail "missing $props" + [ -f "$manifest" ] || fail "missing $manifest" + + say "applying Android branding (gradle.properties + permission trim)" + + python3 - "$props" "$APPLICATION_ID" "$APP_NAME" <<'PY' +import pathlib, re, sys +path = pathlib.Path(sys.argv[1]) +app_id, name = sys.argv[2], sys.argv[3] +text = path.read_text() + +def set_prop(text, key, value): + pat = re.compile(rf"(?m)^{re.escape(key)}=.*$") + line = f"{key}={value}" + if pat.search(text): + return pat.sub(line, text) + return text.rstrip() + "\n" + line + "\n" + +# Prefer plain app.name; clear byte-array form so it cannot win. +text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text) +text = set_prop(text, "app.name", name) +text = set_prop(text, "app.application_id", app_id) +text = set_prop(text, "app.orientation", "portrait") +path.write_text(text) +PY + + python3 - "$manifest" <<'PY' +import pathlib, re, sys +path = pathlib.Path(sys.argv[1]) +text = path.read_text() + +# Drop network / mic / legacy storage, not needed for offline play. +# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads). +# Orientation / label come from gradle.properties placeholders. +for perm in ( + "android.permission.INTERNET", + "android.permission.RECORD_AUDIO", + "android.permission.WRITE_EXTERNAL_STORAGE", +): + text = re.sub( + rf'\s*\s*', + "\n", + text, + ) +text = re.sub(r'\s*android:usesCleartextTraffic="true"', "", text) +path.write_text(text) +PY +} + +# --------------------------------------------------------------- game.love +pack_game_love() { + say "packing game.love for love-android embed flavor" + mkdir -p "$EMBED_ASSETS" + rm -f "$LOVE_FILE" + (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ + main.lua conf.lua src data assets tools/rom_manifest.json \ + -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ + -x 'data/generated/*' -x 'assets/generated/*') + if unzip -Z1 "$LOVE_FILE" \ + | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then + fail "game.love unexpectedly contains generated ROM data" + fi + say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" +} + +# --------------------------------------------------------------- SDK check +require_android_sdk() { + local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + if [ -z "$sdk" ]; then + for candidate in \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk" \ + /usr/local/lib/android/sdk; do + if [ -d "$candidate" ]; then + sdk="$candidate" + break + fi + done + fi + + if [ -z "$sdk" ] || [ ! -d "$sdk" ]; then + fail "Android SDK not found. + Install Android Studio (or command-line tools), then either: + export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk + or create mobile/android/local.properties with: + sdk.dir=/path/to/Android/sdk + love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION + (see mobile/ANDROID.md)." + fi + + export ANDROID_SDK_ROOT="$sdk" + export ANDROID_HOME="$sdk" + + local props="$ANDROID_DIR/local.properties" + # Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick. + printf 'sdk.dir=%s\n' "$sdk" > "$props" + + if ! command -v java >/dev/null 2>&1; then + fail "java not found. Install JDK 17 (Android Studio's bundled JDK is fine)." + fi + + if [ ! -d "$sdk/ndk/$NDK_VERSION" ]; then + warn "NDK $NDK_VERSION not found under $sdk/ndk/" + warn "Install via SDK Manager (Show Package Details → NDK $NDK_VERSION)." + fi +} + +# --------------------------------------------------------------- gradle +run_gradle() { + local task + if $RELEASE; then + task="assembleEmbedNoRecordRelease" + say "building release APK ($task)" + warn "release signing is out-of-band, see mobile/ANDROID.md (Signing)." + warn "without a signingConfig, assembleRelease may produce an unsigned APK or fail." + else + task="assembleEmbedNoRecordDebug" + say "building debug APK ($task), uses the default Android debug keystore" + fi + + if ! ( + cd "$ANDROID_DIR" + ./gradlew --no-daemon "$task" + ); then + fail "gradle $task failed. + Packaging already wrote: $LOVE_FILE + Common causes: missing SDK/NDK $NDK_VERSION, or JDK ≠ 17. See mobile/ANDROID.md. + You can still iterate on the .love payload with: scripts/build_android.sh --package-only" + fi + + local out_dir + if $RELEASE; then + out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/release" + else + out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug" + fi + if [ -d "$out_dir" ]; then + say "APK output:" + find "$out_dir" -name '*.apk' -exec ls -lh {} \; + + local flavor="debug" + $RELEASE && flavor="release" + local dist_dir="$DIST/$flavor" + rm -rf "$dist_dir" + mkdir -p "$dist_dir" + find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; + say "copied to $dist_dir/" + else + warn "gradle finished but no APK dir at $out_dir, check gradle logs above" + fi +} + +# --------------------------------------------------------------- main +apply_android_branding +pack_game_love + +if $PACKAGE_ONLY; then + say "package-only: skipping gradle (game.love + branding ready under mobile/android/)" + exit 0 +fi + +require_android_sdk +run_gradle +say "done" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh new file mode 100755 index 00000000..b58cca72 --- /dev/null +++ b/scripts/build_ios.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's +# official iOS Xcode project (love-11.5-ios-source.zip). +# +# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only] +# +# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO) +# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM) +# --release Release configuration +# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/ +# --package-only Zip game.love + apply plist overlay; skip xcodebuild +# +# Prerequisites: +# - macOS + Xcode (xcodebuild) +# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md) +# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/ +# +# Output: dist/ios/-/PokemonRed.app (convenience copy) +# mobile/ios/build/Build/Products/-/PokemonRed.app + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IOS_DIR="$ROOT/mobile/ios" +LOVE_SRC="$IOS_DIR/love-src" +CACHE="$IOS_DIR/cache" +BUILD_DIR="$IOS_DIR/build" +DIST="$ROOT/dist/ios" +OVERLAY_PLIST="$IOS_DIR/overlays/love-ios.plist" +XCODE_DIR="$LOVE_SRC/platform/xcode" +PROJECT="$XCODE_DIR/love.xcodeproj" +RESOURCES_DIR="$XCODE_DIR/ios/resources" +LOVE_FILE="$RESOURCES_DIR/game.love" +LIBS_DIR="$XCODE_DIR/ios/libraries" + +APP_NAME="PokemonRed" +DISPLAY_NAME="Pokemon Red" +BUNDLE_ID="com.theboisclub.pokemonred" +LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)" +IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip" +APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip" +IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}" +APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}" + +FETCH=false +DEVICE=false +RELEASE=false +PACKAGE_ONLY=false + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --fetch) FETCH=true ;; + --device) DEVICE=true ;; + --release) RELEASE=true ;; + --package-only) PACKAGE_ONLY=true ;; + -h|--help) + sed -n '2,22p' "$0" + exit 0 + ;; + *) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;; + esac + shift +done + +# --------------------------------------------------------------- host checks +if [ "$(uname -s)" != "Darwin" ]; then + fail "iOS builds require macOS (Darwin). This host is $(uname -s). + Run scripts/build_ios.sh on a Mac with Xcode installed." +fi + +if ! $PACKAGE_ONLY; then + command -v xcodebuild >/dev/null 2>&1 \ + || fail "xcodebuild not found. Install Xcode from the App Store, then run: + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" +fi + +# --------------------------------------------------------------- fetch love-src +fetch_love_ios() { + mkdir -p "$CACHE" + local zip_path="$CACHE/$IOS_SOURCE_ZIP" + if [ ! -f "$zip_path" ]; then + say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)" + curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \ + || fail "download failed: $IOS_SOURCE_URL" + else + say "using cached $zip_path" + fi + + say "extracting into $LOVE_SRC" + rm -rf "$LOVE_SRC" + local tmp + tmp="$(mktemp -d "$CACHE/extract.XXXXXX")" + unzip -q "$zip_path" -d "$tmp" + # Zip root is love--ios-source/ + local extracted + extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)" + [ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP" + mv "$extracted" "$LOVE_SRC" + rm -rf "$tmp" + say "love-src ready (LÖVE $LOVE_VERSION)" +} + +if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then + if $FETCH; then + fetch_love_ios + else + fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/. + Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip): + scripts/build_ios.sh --fetch + Or manually: + mkdir -p mobile/ios/cache + curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\ + $IOS_SOURCE_URL + unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache + mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src + See mobile/ios/README.md." + fi +elif $FETCH; then + say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)" +fi + +[ -d "$XCODE_DIR/love.xcodeproj" ] \ + || fail "missing $PROJECT after fetch" + +# --------------------------------------------------------------- apple libraries +require_ios_libraries() { + if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then + return 0 + fi + fail "prebuilt iOS libraries missing at: + $LIBS_DIR + love-ios expects SDL2.xcframework (and friends) there. + + The official love-${LOVE_VERSION}-ios-source.zip normally includes them. + If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip: + + mkdir -p mobile/ios/cache + curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\ + $APPLE_LIBS_URL + unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache + rm -rf mobile/ios/love-src/platform/xcode/ios/libraries + cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\ + mobile/ios/love-src/platform/xcode/ios/libraries + + See mobile/ios/README.md (Apple libraries dependency)." +} + +require_ios_libraries + +# --------------------------------------------------------------- branding / plist +apply_ios_branding() { + [ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST" + local dest="$XCODE_DIR/ios/love-ios.plist" + say "applying iOS branding (portrait-only Info.plist, display name)" + cp "$OVERLAY_PLIST" "$dest" +} + +# --------------------------------------------------------------- game.love +pack_game_love() { + say "packing game.love for love-ios resources" + mkdir -p "$RESOURCES_DIR" + rm -f "$LOVE_FILE" + # Same payload as scripts/build.sh / build_android.sh: game sources only. + (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ + main.lua conf.lua src data assets tools/rom_manifest.json \ + -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ + -x 'data/generated/*' -x 'assets/generated/*') + if unzip -Z1 "$LOVE_FILE" \ + | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then + fail "game.love unexpectedly contains generated ROM data" + fi + say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" +} + +# Ensure game.love is in the love-ios Copy Bundle Resources phase (idempotent). +ensure_game_love_in_xcode() { + local pbx="$XCODE_DIR/love.xcodeproj/project.pbxproj" + [ -f "$pbx" ] || fail "missing $pbx" + + if grep -q 'ios/resources/game.love' "$pbx"; then + return 0 + fi + + say "wiring game.love into love-ios Copy Bundle Resources" + python3 - "$pbx" <<'PY' +import pathlib, sys +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +if "ios/resources/game.love" in text: + raise SystemExit(0) + +file_ref = "A1B2C3D41E5F678901234567" +build_file = "A1B2C3D41E5F678901234568" + +file_ref_entry = ( + f"\t\t{file_ref} /* game.love */ = {{isa = PBXFileReference; " + f"lastKnownFileType = file; name = game.love; " + f'path = ios/resources/game.love; sourceTree = ""; }};\n' +) +build_file_entry = ( + f"\t\t{build_file} /* game.love in Resources */ = {{isa = PBXBuildFile; " + f"fileRef = {file_ref} /* game.love */; }};\n" +) + +# PBXBuildFile section +marker = "/* Begin PBXBuildFile section */\n" +if marker not in text: + raise SystemExit("PBXBuildFile section not found") +text = text.replace(marker, marker + build_file_entry, 1) + +# PBXFileReference section +marker = "/* Begin PBXFileReference section */\n" +if marker not in text: + raise SystemExit("PBXFileReference section not found") +text = text.replace(marker, marker + file_ref_entry, 1) + +# Add to love-ios Resources build phase (FA0B7F041A95AAF3000E1D17) +old = ( + "\t\tFA0B7F041A95AAF3000E1D17 /* Resources */ = {\n" + "\t\t\tisa = PBXResourcesBuildPhase;\n" + "\t\t\tbuildActionMask = 2147483647;\n" + "\t\t\tfiles = (\n" + "\t\t\t\tFA5D249C1A96CF4300C6FC8F /* Images.xcassets in Resources */,\n" + "\t\t\t\tFA7C636A1A9C49570000FD29 /* Launch Screen.xib in Resources */,\n" + "\t\t\t);\n" +) +new = ( + "\t\tFA0B7F041A95AAF3000E1D17 /* Resources */ = {\n" + "\t\t\tisa = PBXResourcesBuildPhase;\n" + "\t\t\tbuildActionMask = 2147483647;\n" + "\t\t\tfiles = (\n" + "\t\t\t\tFA5D249C1A96CF4300C6FC8F /* Images.xcassets in Resources */,\n" + "\t\t\t\tFA7C636A1A9C49570000FD29 /* Launch Screen.xib in Resources */,\n" + f"\t\t\t\t{build_file} /* game.love in Resources */,\n" + "\t\t\t);\n" +) +if old not in text: + # Fallback: insert before the closing of that files = ( list if markers differ slightly + needle = "\t\tFA0B7F041A95AAF3000E1D17 /* Resources */ = {" + if needle not in text: + raise SystemExit("love-ios Resources build phase not found") + # Insert build file line after "files = (" within that block + idx = text.index(needle) + files_idx = text.index("files = (", idx) + insert_at = text.index("\n", files_idx) + 1 + text = ( + text[:insert_at] + + f"\t\t\t\t{build_file} /* game.love in Resources */,\n" + + text[insert_at:] + ) +else: + text = text.replace(old, new, 1) + +# Add file ref to the ios group if present +ios_group = "FA5D24961A96CE0A00C6FC8F /* ios */ = {" +if ios_group in text and file_ref not in text[text.index(ios_group):text.index(ios_group)+400]: + # Prefer adding under Resources group, skip if structure unknown; path is absolute enough via sourceTree + pass + +path.write_text(text) +print("patched project.pbxproj") +PY +} + +# --------------------------------------------------------------- xcodebuild +run_xcodebuild() { + local config sdk destination + if $RELEASE; then + config="Release" + else + config="Debug" + fi + + if $DEVICE; then + sdk="iphoneos" + destination="generic/platform=iOS" + else + sdk="iphonesimulator" + destination="generic/platform=iOS Simulator" + fi + + mkdir -p "$BUILD_DIR" + + # Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires + # -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes. + local args=( + -project "$PROJECT" + -target love-ios + -configuration "$config" + -sdk "$sdk" + -destination "$destination" + SYMROOT="$BUILD_DIR/Build/Products" + OBJROOT="$BUILD_DIR/Build/Intermediates" + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" + PRODUCT_NAME="$APP_NAME" + MARKETING_VERSION="$LOVE_VERSION" + ONLY_ACTIVE_ARCH=NO + ) + + if ! $DEVICE; then + # Simulator: no signing required + args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=) + else + warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY" + if [ -n "${DEVELOPMENT_TEAM:-}" ]; then + args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM") + fi + if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then + args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY") + fi + fi + + if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then + fail "Xcode SDK '$sdk' is not installed (xcodebuild -showsdks). + Open Xcode → Settings → Platforms (or Components) and install iOS. + Simulator builds need the iOS Simulator platform; device builds need iOS." + fi + + say "xcodebuild love-ios ($config / $sdk)" + set +e + ( + cd "$XCODE_DIR" + xcodebuild "${args[@]}" + ) + local xc_status=$? + set -e + if [ "$xc_status" -ne 0 ]; then + fail "xcodebuild failed (exit $xc_status). + Common causes: + - iOS platform/SDK not installed in Xcode (Settings → Platforms) + - device build without DEVELOPMENT_TEAM / provisioning (see mobile/ios/README.md) + - Xcode too new for LÖVE $LOVE_VERSION sources (try an older Xcode) + Packaging still succeeded: $LOVE_FILE" + fi + + local products="$BUILD_DIR/Build/Products/${config}-${sdk}" + local app="$products/$APP_NAME.app" + if [ ! -d "$app" ]; then + # PRODUCT_NAME override can still leave love.app on older projects + if [ -d "$products/love.app" ]; then + app="$products/love.app" + warn "built app is love.app (PRODUCT_NAME override not applied); fusing game.love anyway" + else + warn "xcodebuild finished but no .app under $products" + find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true + return 0 + fi + fi + + # Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love. + if [ ! -f "$app/game.love" ]; then + say "fusing game.love into $(basename "$app")" + cp "$LOVE_FILE" "$app/game.love" + fi + + local dist_dir="$DIST/${config}-${sdk}" + rm -rf "$dist_dir" + mkdir -p "$dist_dir" + cp -R "$app" "$dist_dir/" + say "copied to $dist_dir/$(basename "$app")" + + say "iOS app: $app" + say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME" + if $DEVICE; then + warn "signing/provisioning is manual, see mobile/ios/README.md" + else + say "simulator tip: xcrun simctl install booted \"$app\"" + fi +} + +# --------------------------------------------------------------- main +apply_ios_branding +pack_game_love +ensure_game_love_in_xcode + +if $PACKAGE_ONLY; then + say "package-only: skipping xcodebuild (game.love + plist ready under mobile/ios/love-src/)" + exit 0 +fi + +run_xcodebuild +say "done" diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist new file mode 100644 index 00000000..48f7bf5c --- /dev/null +++ b/scripts/macos-entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/scripts/play.ps1 b/scripts/play.ps1 new file mode 100644 index 00000000..2e31969b --- /dev/null +++ b/scripts/play.ps1 @@ -0,0 +1,15 @@ +# One-shot: full setup, then launch the game (Windows). +# +# Usage: powershell -ExecutionPolicy Bypass -File scripts\play.ps1 [-Rom red.gb] + +param( + [string]$Rom = $env:ROM_PATH +) + +$ErrorActionPreference = 'Stop' + +& (Join-Path $PSScriptRoot 'setup.ps1') -Rom $Rom +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +& (Join-Path $PSScriptRoot 'run.ps1') +exit $LASTEXITCODE diff --git a/scripts/play.sh b/scripts/play.sh new file mode 100755 index 00000000..b9e1ae14 --- /dev/null +++ b/scripts/play.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# One-shot: full setup, then launch the game (macOS-friendly). +# +# Usage: scripts/play.sh [--rom /path/to/pokemon-red.gb] + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" + +"$HERE/setup.sh" "$@" +exec "$HERE/run.sh" diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 00000000..14027f44 --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,47 @@ +# Run the LÖVE2D Pokémon Red port (Windows). +# +# Assumes scripts\setup.ps1 has been run once (generated data present and +# LÖVE installed). Extra arguments are passed through to LÖVE. +# +# Link play is peer-to-peer over lua-enet (bundled with LÖVE): one player +# uses START > LINK > HOST A GAME, the other joins the shown address. +# UDP port defaults to 7777; override with $env:POKEPORT_LINK_PORT. + +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot + +function Fail($msg) { Write-Host "error: $msg" -ForegroundColor Red; exit 1 } + +if (-not (Test-Path (Join-Path $Root 'data\generated\maps.lua'))) { + Fail 'generated data missing, run scripts\setup.ps1 first' +} + +# Prefer lovec.exe (console-attached) so print output lands in the terminal; +# love.exe is a GUI-subsystem binary that swallows stdout. +function Find-Love { + foreach ($name in 'lovec', 'love') { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + } + $dirs = @( + "$env:ProgramFiles\LOVE", + "${env:ProgramFiles(x86)}\LOVE", + "$env:LOCALAPPDATA\Programs\LOVE" + ) + foreach ($d in $dirs) { + foreach ($name in 'lovec.exe', 'love.exe') { + $p = Join-Path $d $name + if ($d -and (Test-Path $p)) { return $p } + } + } + return $null +} + +$LoveBin = Find-Love +if (-not $LoveBin) { + Fail 'LÖVE not found, run scripts\setup.ps1 (or install from https://love2d.org)' +} + +& $LoveBin $Root @args +exit $LASTEXITCODE diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 00000000..dc08f50d --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run the LÖVE2D Pokémon Red port (macOS-friendly). +# +# Assumes scripts/setup.sh has been run once (generated data present and +# LÖVE installed). Extra arguments are passed through to LÖVE. +# +# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player +# picks HOST A GAME in START > LINK and reads out the address shown; +# the other picks JOIN A GAME and types it in. UDP port 7777 by +# default (override with POKEPORT_LINK_PORT on both sides). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +[ -f "$ROOT/data/generated/maps.lua" ] \ + || fail "generated data missing, run scripts/setup.sh first" + +find_love() { + command -v love >/dev/null 2>&1 && { echo "love"; return; } + for app in "/Applications/love.app" "$HOME/Applications/love.app"; do + if [ -x "$app/Contents/MacOS/love" ]; then + echo "$app/Contents/MacOS/love" + return + fi + done + return 1 +} + +LOVE_BIN="$(find_love)" \ + || fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" + +exec "$LOVE_BIN" "$ROOT" "$@" diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 new file mode 100644 index 00000000..7abecddd --- /dev/null +++ b/scripts/setup.ps1 @@ -0,0 +1,100 @@ +# Build game data from a user-provided Pokemon Red ROM and install LÖVE. +# +# Usage: +# powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb +# +# With no explicit path, the first *.gb file in the project root is used. + +param( + [string]$Rom = $env:ROM_PATH +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$Venv = Join-Path $Root '.venv' + +function Say($msg) { Write-Host "==> $msg" -ForegroundColor Green } +function Fail($msg) { Write-Host "error: $msg" -ForegroundColor Red; exit 1 } + +function Find-Python { + if (Get-Command py -ErrorAction SilentlyContinue) { + try { + if ((& py -3 --version 2>$null) -match '^Python 3') { + return @('py', '-3') + } + } catch {} + } + if (Get-Command python -ErrorAction SilentlyContinue) { + try { + if ((& python --version 2>$null) -match '^Python 3') { + return @('python') + } + } catch {} + } + return $null +} + +$Python = @(Find-Python) +if (-not $Python) { Fail 'Python 3 is required to decode the ROM' } +$PyExe = $Python[0] +$PyArgs = @($Python | Select-Object -Skip 1) + +if (-not $Rom) { + $candidate = Get-ChildItem -Path $Root -Filter '*.gb' -File | + Select-Object -First 1 + if ($candidate) { $Rom = $candidate.FullName } +} +if (-not $Rom -or -not (Test-Path -LiteralPath $Rom -PathType Leaf)) { + Fail "Pokemon Red ROM not found. Put your .gb file in $Root or pass -Rom C:\path\red.gb" +} +$Rom = (Resolve-Path -LiteralPath $Rom).Path + +$VenvPython = Join-Path $Venv 'Scripts\python.exe' +if (-not (Test-Path $VenvPython)) { + Say 'creating Python environment' + & $PyExe @PyArgs -m venv $Venv + if ($LASTEXITCODE -ne 0) { Fail 'venv creation failed' } +} +Say 'installing Pillow' +& $VenvPython -m pip install --quiet --upgrade pip +& $VenvPython -m pip install --quiet pillow +if ($LASTEXITCODE -ne 0) { Fail 'Pillow installation failed' } + +Say "decoding game data from $(Split-Path -Leaf $Rom)" +Push-Location $Root +try { + & $VenvPython 'tools\build_data.py' --rom $Rom --clean + if ($LASTEXITCODE -ne 0) { Fail 'ROM extraction failed' } +} finally { + Pop-Location +} + +function Find-Love { + foreach ($name in 'lovec', 'love') { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + } + foreach ($dir in @( + "$env:ProgramFiles\LOVE", + "${env:ProgramFiles(x86)}\LOVE", + "$env:LOCALAPPDATA\Programs\LOVE" + )) { + if ($dir) { + $candidate = Join-Path $dir 'love.exe' + if (Test-Path $candidate) { return $candidate } + } + } + return $null +} + +if (Find-Love) { + Say 'LÖVE found' +} elseif (Get-Command winget -ErrorAction SilentlyContinue) { + Say 'installing LÖVE via winget' + winget install --exact --id Love2d.Love2d --accept-source-agreements --accept-package-agreements +} else { + Fail 'LÖVE 11.x is not installed; install it from https://love2d.org' +} + +Say 'setup complete. Start the game with: scripts\run.ps1' +exit 0 diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 00000000..5c1ae093 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Build game data from a user-provided Pokemon Red ROM and install LÖVE. +# +# Usage: +# scripts/setup.sh --rom /path/to/pokemon-red.gb +# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh +# +# With no explicit path, the first *.gb file in the project root is used. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENV="$ROOT/.venv" +ROM="${ROM_PATH:-}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --rom) + [ "$#" -ge 2 ] || { echo "error: --rom needs a path" >&2; exit 2; } + ROM="$2" + shift 2 + ;; + *) + echo "error: unknown option: $1" >&2 + exit 2 + ;; + esac +done + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +command -v python3 >/dev/null 2>&1 \ + || fail "Python 3 is required to decode the ROM" + +if [ -z "$ROM" ]; then + for candidate in "$ROOT"/*.gb; do + if [ -f "$candidate" ]; then + ROM="$candidate" + break + fi + done +fi +[ -n "$ROM" ] && [ -f "$ROM" ] \ + || fail "Pokemon Red ROM not found. Put your .gb file in $ROOT or pass --rom /path/to/file.gb" + +if [ ! -x "$VENV/bin/python3" ]; then + say "creating Python environment" + python3 -m venv "$VENV" +fi +say "installing Pillow" +"$VENV/bin/python3" -m pip install --quiet --upgrade pip +"$VENV/bin/python3" -m pip install --quiet pillow + +say "decoding game data from $(basename "$ROM")" +cd "$ROOT" +"$VENV/bin/python3" tools/build_data.py --rom "$ROM" --clean + +find_love() { + command -v love >/dev/null 2>&1 && { echo "love"; return; } + for app in "/Applications/love.app" "$HOME/Applications/love.app"; do + if [ -x "$app/Contents/MacOS/love" ]; then + echo "$app/Contents/MacOS/love" + return + fi + done + return 1 +} + +if LOVE_BIN="$(find_love)"; then + say "LÖVE found: $LOVE_BIN" +elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then + say "installing LÖVE via Homebrew" + brew install --cask love +else + fail "LÖVE 11.x is not installed; install it from https://love2d.org" +fi + +say "setup complete. Start the game with: scripts/run.sh" diff --git a/scripts/split_web_build.py b/scripts/split_web_build.py new file mode 100755 index 00000000..51c391b7 --- /dev/null +++ b/scripts/split_web_build.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Split a love.js web build's game.data into in index.html. +""" +import argparse +import os +import re +import sys + +FETCH_REMOTE_PACKAGE_RE = re.compile( + r" function fetchRemotePackage\(packageName, packageSize, callback, errback\) \{.*?\n \};\n", + re.DOTALL, +) + +def make_patched_fetch(chunk_size): + return f""" function fetchRemotePackage(packageName, packageSize, callback, errback) {{ + var CHUNK_SIZE = {chunk_size}; + var numChunks = Math.ceil(packageSize / CHUNK_SIZE); + var buffer = new Uint8Array(packageSize); + var loadedChunks = 0; + var hadError = false; + + function chunkURL(i) {{ + return packageName + '.part' + ('000' + i).slice(-3); + }} + + function onChunkLoaded(i, data) {{ + buffer.set(new Uint8Array(data), i * CHUNK_SIZE); + loadedChunks++; + if (Module['setStatus']) Module['setStatus']('Downloading data... (' + loadedChunks + '/' + numChunks + ' parts)'); + if (loadedChunks === numChunks && !hadError) {{ + callback(buffer.buffer); + }} + }} + + for (var i = 0; i < numChunks; i++) {{ + (function(i) {{ + var xhr = new XMLHttpRequest(); + xhr.open('GET', chunkURL(i), true); + xhr.responseType = 'arraybuffer'; + xhr.onload = function() {{ + if (xhr.status == 200 || xhr.status == 304 || xhr.status == 206 || (xhr.status == 0 && xhr.response)) {{ + onChunkLoaded(i, xhr.response); + }} else if (!hadError) {{ + hadError = true; + errback(new Error(xhr.statusText + " : " + chunkURL(i))); + }} + }}; + xhr.onerror = function() {{ + if (!hadError) {{ hadError = true; errback(new Error("NetworkError for: " + chunkURL(i))); }} + }}; + xhr.send(null); + }})(i); + }} + }}; +""" + +def split_file(src_path, chunk_size): + parts = [] + with open(src_path, 'rb') as f: + i = 0 + while True: + data = f.read(chunk_size) + if not data: + break + part_path = f"{src_path}.part{i:03d}" + with open(part_path, 'wb') as out: + out.write(data) + parts.append(part_path) + i += 1 + return parts + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('outdir', help='love.js output directory (e.g. .bazinga/online)') + ap.add_argument('--chunk-size-mb', type=int, default=20, help='max chunk size in MB (default 20, keep under host cap e.g. 25MB)') + args = ap.parse_args() + + chunk_size = args.chunk_size_mb * 1024 * 1024 + game_data = os.path.join(args.outdir, 'game.data') + game_js = os.path.join(args.outdir, 'game.js') + + if not os.path.isfile(game_data): + sys.exit(f"error: {game_data} not found, run love.js first") + if not os.path.isfile(game_js): + sys.exit(f"error: {game_js} not found, run love.js first") + + # clean up any stale parts from a previous run + for name in os.listdir(args.outdir): + if re.match(r"^game\.data\.part\d{3}$", name): + os.remove(os.path.join(args.outdir, name)) + + parts = split_file(game_data, chunk_size) + os.remove(game_data) + + with open(game_js, 'r') as f: + src = f.read() + + if not FETCH_REMOTE_PACKAGE_RE.search(src): + sys.exit("error: could not find fetchRemotePackage() in game.js, love.js template may have changed") + + patched = FETCH_REMOTE_PACKAGE_RE.sub(make_patched_fetch(chunk_size), src, count=1) + with open(game_js, 'w') as f: + f.write(patched) + + sizes = [os.path.getsize(p) for p in parts] + print(f"split game.data into {len(parts)} parts (chunk size {args.chunk_size_mb}MB):") + for p, s in zip(parts, sizes): + print(f" {os.path.basename(p)}: {s / 1024 / 1024:.1f} MB") + print("patched game.js fetchRemotePackage() to fetch+reassemble parts") + +if __name__ == '__main__': + main() diff --git a/scripts/web-theme/bg.png b/scripts/web-theme/bg.png new file mode 100644 index 00000000..77ef798d Binary files /dev/null and b/scripts/web-theme/bg.png differ diff --git a/scripts/web-theme/love.css b/scripts/web-theme/love.css new file mode 100644 index 00000000..70c54251 --- /dev/null +++ b/scripts/web-theme/love.css @@ -0,0 +1,49 @@ +* { + box-sizing: border-box; +} + +h1 { + font-family: arial; + color: rgb( 11, 86, 117 ); +} + +body { + background-image: url(bg.png); + background-repeat: no-repeat; + font-family: arial; + margin: 0; + padding: none; + background-color: rgb( 154, 205, 237 ); + color: rgb( 28, 78, 104 ); +} + +footer { + font-family: arial; + font-size: 12px; + padding-left: 10px; + position:absolute; + bottom: 0; + width: 100%; +} + +/* Links */ +a { + text-decoration: none; +} +a:link { + color: rgb( 233, 73, 154 ); +} +a:visited { + color: rgb( 110, 30, 71 ); +} +a:hover { + color: rgb( 252, 207, 230 ); +} + +/* the canvas *must not* have any border or padding, or mouse coords will be wrong */ +#canvas { + padding-right: 0; + display: block; + border: 0px none; + visibility: hidden; +} diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua new file mode 100644 index 00000000..00e16dc0 --- /dev/null +++ b/src/battle/AnimPlayer.lua @@ -0,0 +1,749 @@ +-- Plays back the original battle move animations (beams, blobs, rings...) +-- from data/generated/battle_anims.lua, reimplementing the subanimation +-- player in pokered's engine/battle/animations.asm: +-- +-- PlayAnimation (:164) walks a move's battle_anim rows: subanimation +-- rows (with a tileset + per-frame-block delay) and +-- 2-byte special-effect rows (SE_*). +-- LoadSubanimation (:270) resolves the subanimation type into a transform: +-- on the PLAYER's turn every type but ENEMY plays +-- untransformed and ENEMY plays HFLIP'd; on the +-- ENEMY's turn the type applies as-is and ENEMY +-- plays untransformed (GetSubanimationTransform1/2, +-- :333/:346). REVERSE plays the frame-block list +-- back to front, untransformed. +-- PlaySubanimation (:580) draws each frame block at its base coordinate, +-- writing OAM entries from a cursor that resets to +-- slot 0 at the start of every subanimation row. +-- DrawFrameBlock (:3) per-tile transforms (8-bit math, OAM space): +-- HVFLIP: y' = 136-(base+dy), x' = 168-(base+dx), +-- toggles both flip bits (flags with +-- PRIO/PAL bits become no-flip) +-- HFLIP: y' = base+dy+40, x' = 168-(base+dx), +-- toggles the x-flip bit +-- COORDFLIP: y' = (136-base)+dy, x' = (168-base)+dx +-- then applies the frame-block mode: +-- 0/1: show for `delay` frames, then clear all +-- sprites (one extra frame; GROWL skips the +-- clear, :145) and restart at OAM slot 0 +-- 2: accumulate; no delay, keep the cursor moving +-- 3: show for `delay` frames, keep sprites and +-- keep the cursor moving (persistent trails) +-- 4: show for `delay` frames, keep sprites but +-- rewind the cursor (next block overwrites) +-- +-- Special-effect rows are timed here (each SE_* gets the frame count its +-- routine really blocks for -- see SE_FRAMES) and exposed through +-- :pollEffects() so the caller can route them into the screen fx layer +-- (palette fades, mon-pic slides, screen shakes; BattleState implements +-- the visuals). The sprite-emitter effects (AnimationSpiralBallsInward, +-- AnimationShootBallsUpward/ShootManyBallsUpward, +-- AnimationWaterDropletsEverywhere, AnimationLeavesFalling/PetalsFalling) +-- ARE executed here: their OAM trajectories are compiled into sprite +-- steps at start() from the original routines' math. +-- +-- Per-animation-id frame-block effects (DoSpecialEffectByAnimationId, +-- data/battle_anims/special_effects.asm) are also compiled in: the +-- screen-flash pulses of Mega Punch/Blizzard/Thunderbolt/Explosion..., +-- Rock Slide's 1px rumble, and Explosion's user-pic hide, each timed at +-- the wSubAnimCounter values the asm checks. +-- +-- Events carry the row sounds too ({ sound = }): PlayAnimation +-- plays each row's MoveSoundTable entry (with its pitch/tempo modifiers) +-- as the row starts, via GetMoveSound. +-- +-- Usage (one :update() per 60fps frame): +-- local player = AnimPlayer.new(require("data.generated.battle_anims")) +-- player:start("THUNDERBOLT", true) +-- ... player:update(); player:draw(); player:pollEffects() ... +-- until player:isDone() +-- +-- start() takes an optional opts table: { shakes = n } replays each +-- subanimation row n times, opening every pass with an SFX_TINK event +-- and a 40-frame pause -- DoBallShakeSpecialEffects (:739), which rewinds +-- the ball-shake subanimation wNumShakes times. { ball = "" } +-- marks a ball-toss row with the thrown ball (wCurItem): a MASTER_BALL +-- or ULTRA_BALL toss flickers the OBJ palette every frame block -- +-- DoBallTossSpecialEffects (:685) XORs rOBP0 with %00111100. + +local Logger = require("src.core.Logger") + +local AnimPlayer = {} +AnimPlayer.__index = AnimPlayer + +local SE_PAUSE_FRAMES = 8 -- fallback pacing for unknown SE rows + +-- Frames each special-effect routine blocks the animation for +-- (engine/battle/animations.asm; delays counted from the routines' +-- DelayFrames calls). 0 = a bare register write (palette sets). +local SE_FRAMES = { + SE_DARK_SCREEN_FLASH = 4, -- AnimationFlashScreen: 2f inverted + 2f white + SE_FLASH_SCREEN_LONG = 48, -- 12 palettes x (2f + 1f + 1f) over 3 cycles + SE_DARK_SCREEN_PALETTE = 0, -- SetAnimationBGPalette writes + SE_LIGHT_SCREEN_PALETTE = 0, + 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_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 + SE_SLIDE_MON_HALF_OFF = 19, -- 4 tile steps x 4f + Delay3 + SE_SLIDE_MON_UP = 14, -- 7 row shifts x 2f (cyclic wrap) + SE_SLIDE_MON_DOWN = 21, -- 7 rows x Delay3 + SE_SLIDE_MON_DOWN_AND_HIDE = 19, -- 2 x 8f + Delay3 + SE_MOVE_MON_HORIZONTALLY = 3, + SE_RESET_MON_POSITION = 3, + SE_SHAKE_BACK_AND_FORTH = 96, -- 16 loops x 2 redraws x Delay3 + SE_BOUNCE_UP_AND_DOWN = 108, -- 5 x AnimationSlideMonDown + Delay3 + SE_SQUISH_MON_PIC = 26, -- 4 loops x 2 x Delay3 + 2f + 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_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4, + SE_TRANSFORM_MON = 4, + SE_SUBSTITUTE_MON = 3, + SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames +} + +-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects: +-- per-frame-block effects keyed on the animation id. "flash" = +-- AnimationFlashScreen after every frame block. +local ANIM_ID_FX = { + MEGA_PUNCH = "flash", GUILLOTINE = "flash", MEGA_KICK = "flash", + HEADBUTT = "flash", DISABLE = "flash", BUBBLEBEAM = "flash", + REFLECT = "flash", SPORE = "flash", + BLIZZARD = "blizzard", -- flash at counters 13/9/5/1 + HYPER_BEAM = "every4", -- flash when counter % 4 == 0 + THUNDERBOLT = "every8", -- flash when counter % 8 == 0 + SELFDESTRUCT = "explode", EXPLOSION = "explode", + ROCK_SLIDE = "rockslide", -- 1px shakes at 8-11, flash at 1 +} + +-- Anim tiles load at vSprites tile $31 (LoadMoveAnimationTiles), so the +-- raw VRAM tile ids the emitter routines poke into OAM are sheet tile +-- (id - $31). +local BALL_TILE = 0x7a - 0x31 -- AnimationSpiralBallsInward/ShootBalls +local DROPLET_TILE = 0x71 - 0x31 -- AnimationWaterDropletsEverywhere (ts 0) +local LEAF_TILE = 0x37 - 0x31 -- AnimationLeavesFalling (ts 1) +local PETAL_TILE = 0x71 - 0x31 -- AnimationPetalsFalling (ts 1) + +function AnimPlayer.new(data) + return setmetatable({ + data = data, + images = {}, -- tileset id -> Image | false (load failed) + quads = {}, -- tileset id -> { [tile] = Quad } + warned = {}, + steps = {}, -- { dur = frames, sprites = { {x,y,tile,ts,xf,yf}... } } + events = {}, -- { effect = "SE_*", frame = n } in firing order + stepIndex = 1, + stepLeft = 0, + elapsed = 0, + eventCursor = 1, + }, AnimPlayer) +end + +function AnimPlayer:warnOnce(key, fmt, ...) + if not self.warned[key] then + self.warned[key] = true + Logger.warn(fmt, ...) + end +end + +-- engine/battle/animations.asm GetSubanimationTransform1/2 +local function resolveTransform(subType, attackerIsPlayer) + if subType == "ENEMY" then + return attackerIsPlayer and "HFLIP" or "NORMAL" + end + return attackerIsPlayer and "NORMAL" or subType +end + +local function wrap(v) return v % 256 end + +-- ------------------------------------------------------------------ +-- Sprite-emitter special effects, compiled to per-frame sprite steps. +-- Coordinates are OAM space (screen x+8 / y+16) like the frame blocks; +-- obp marks which hardware OBJ palette the routine ran under ("e4" = +-- ambient rOBP0, "f0" = wAnimPalette on SGB, "obp1" = rOBP1 $6c). +-- ------------------------------------------------------------------ + +-- AnimationSpiralBallsInward (:1480): 3 ball sprites walk the 21-entry +-- coordinate spiral, one entry per 5 frames, player-anchored at (0,0) +-- and enemy-anchored at (y-40, x+80); ends with AnimationFlashScreen. +local SPIRAL_COORDS = { -- y, x pairs (SpiralBallAnimationCoordinates) + {0x38,0x28},{0x40,0x18},{0x50,0x10},{0x60,0x18},{0x68,0x28},{0x60,0x38}, + {0x50,0x40},{0x40,0x38},{0x40,0x28},{0x46,0x1E},{0x50,0x18},{0x5B,0x1E}, + {0x60,0x28},{0x5B,0x32},{0x50,0x38},{0x46,0x32},{0x48,0x28},{0x50,0x20}, + {0x58,0x28},{0x50,0x30},{0x50,0x28}, +} +local function spiralBallSteps(attackerIsPlayer) + local by, bx = 0, 0 + if not attackerIsPlayer then by, bx = -40, 80 end + local steps = {} + for k = 1, #SPIRAL_COORDS - 2 do -- the step aborts when a ball reads the -1 + local sprites = {} + for i = 0, 2 do + local c = SPIRAL_COORDS[k + i] + sprites[#sprites + 1] = { x = wrap(bx + c[2]), y = wrap(by + c[1]), + tile = BALL_TILE, ts = 0, obp = "e4" } + end + steps[#steps + 1] = { dur = 5, sprites = sprites } + end + return steps +end + +-- _AnimationShootBallsUpward (:1638): a pillar of `n` balls at x, from +-- baseY+8*i, each moving up 4px per frame and vanishing at baseY+8. +local function shootPillarSteps(steps, n, x, baseY) + local ys = {} + for i = 1, n do ys[i] = baseY + 8 * i end + local function snapshot() + local sprites = {} + for i = 1, n do + if ys[i] then + sprites[#sprites + 1] = { x = x, y = wrap(ys[i]), tile = BALL_TILE, + ts = 0, obp = "e4" } + end + end + return sprites + end + steps[#steps + 1] = { dur = 1, sprites = snapshot() } -- init DelayFrame + local alive = n + while alive > 0 do + for i = 1, n do + if ys[i] then + if ys[i] == baseY + 8 then + ys[i] = nil + alive = alive - 1 + else + ys[i] = ys[i] - 4 + end + end + end + steps[#steps + 1] = { dur = 1, sprites = snapshot() } + end +end + +-- AnimationShootBallsUpward (:1617): one 5-ball pillar; player at +-- (x=5*8, baseY=6*8), enemy at (x=16*8, baseY=0). +local function shootBallsSteps(attackerIsPlayer) + local steps = {} + if attackerIsPlayer then + shootPillarSteps(steps, 5, 5 * 8, 6 * 8) + else + shootPillarSteps(steps, 5, 16 * 8, 0) + end + return steps +end + +-- AnimationShootManyBallsUpward (:1686): six sequential 4-ball pillars. +local function shootManyBallsSteps(attackerIsPlayer) + local xs = attackerIsPlayer + and { 0x10, 0x40, 0x28, 0x18, 0x38, 0x30 } + or { 0x60, 0x90, 0x78, 0x68, 0x88, 0x80 } + local baseY = attackerIsPlayer and 0x50 or 0x28 + local steps = {} + for _, x in ipairs(xs) do + shootPillarSteps(steps, 4, x, baseY) + end + return steps +end + +-- AnimationWaterDropletsEverywhere (:1114): 64 one-frame passes of +-- droplet rows; the 8-bit x cursor persists across passes, which is +-- what makes the field scroll. +local function waterDropletSteps() + local steps = {} + local baseX = 0xF0 -- ld a, -16 + for _ = 1, 32 do + for _, startY in ipairs({ 16, 24 }) do + local sprites = {} + local y = startY + while true do + baseX = wrap(baseX + 27) + sprites[#sprites + 1] = { x = baseX, y = y, tile = DROPLET_TILE, + ts = 0, obp = "e4" } + if baseX >= 144 then + baseX = wrap(baseX - 168) + y = y + 16 + if y >= 112 then break end + end + end + steps[#steps + 1] = { dur = 1, sprites = sprites } + end + end + return steps +end + +-- AnimationFallingObjects (:2335): n objects fall 2px per 3-frame tick, +-- swaying via the delta-X table (index advances each tick, direction +-- flips past index 8), until object 1 reaches y=104. +local FALLING_X = { 0x38,0x40,0x50,0x60,0x70,0x88,0x90,0x56,0x67,0x4A, + 0x77,0x84,0x98,0x32,0x22,0x5C,0x6C,0x7D,0x8E,0x99 } +local FALLING_M = { 0x00,0x84,0x06,0x81,0x02,0x88,0x01,0x83,0x05,0x89, + 0x09,0x80,0x07,0x87,0x03,0x82,0x04,0x85,0x08,0x86 } +local FALLING_DX = { [0]=0, 1, 3, 5, 7, 9, 11, 13, 15 } +local function fallingObjectSteps(n, tile, obp) + local objs = {} + for i = 1, n do + objs[i] = { y = (i == 1) and 0 or 8 * i, x = FALLING_X[i], + m = FALLING_M[i], xf = false } + end + local steps = {} + while objs[1].y ~= 104 do + local sprites = {} + for i = 1, n do + local o = objs[i] + -- FallingObjects_UpdateMovementByte runs before the OAM update + local left = o.m >= 0x80 + local idx = (o.m % 0x80) + 1 + if idx == 9 then + left = not left + idx = 0 + end + o.m = (left and 0x80 or 0) + idx + o.y = o.y + 2 + if o.y >= 112 then o.y = 160 end -- parked off-screen + local dx = FALLING_DX[idx] + o.x = left and wrap(o.x - dx) or wrap(o.x + dx) + o.xf = left + sprites[#sprites + 1] = { x = o.x, y = o.y, tile = tile, ts = 1, + xf = o.xf, obp = obp } + end + steps[#steps + 1] = { dur = 3, sprites = sprites } + if #steps > 120 then break end -- safety; the asm exits at 52 ticks + end + return steps +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, + SE_SHOOT_MANY_BALLS_UPWARD = function(isPlayer) return shootManyBallsSteps(isPlayer) end, + SE_WATER_DROPLETS_EVERYWHERE = function() return waterDropletSteps() end, + -- AnimationLeavesFalling runs under wAnimPalette ($f0 on SGB); + -- petals keep the ambient $e4 + SE_LEAVES_FALLING = function() return fallingObjectSteps(3, LEAF_TILE, "f0") end, + SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end, +} + +-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`, +-- with the subanimation transform applied (DrawFrameBlock). +local function placeTile(transform, bc, t, tileset) + local x, y, xf, yf + if transform == "HVFLIP" then + y = wrap(136 - wrap(bc.y + t.y)) + x = wrap(168 - wrap(bc.x + t.x)) + -- the engine compares the whole flags byte: plain/xflip/yflip toggle + -- both bits, any other combination (both, PRIO, PAL1) becomes no-flip + local plain = not (t.prio or t.pal1) + if plain and not t.xflip and not t.yflip then + xf, yf = true, true + elseif plain and t.xflip and not t.yflip then + xf, yf = false, true + elseif plain and t.yflip and not t.xflip then + xf, yf = true, false + else + xf, yf = false, false + end + elseif transform == "HFLIP" then + y = wrap(wrap(bc.y + t.y) + 40) + x = wrap(168 - wrap(bc.x + t.x)) + xf, yf = not t.xflip, t.yflip + elseif transform == "COORDFLIP" then + y = wrap(wrap(136 - bc.y) + t.y) + x = wrap(wrap(168 - bc.x) + t.x) + xf, yf = t.xflip, t.yflip + else -- NORMAL (and REVERSE, which only reorders the block list) + y = wrap(bc.y + t.y) + x = wrap(bc.x + t.x) + xf, yf = t.xflip, t.yflip + end + -- OAM_PAL1 tiles render through rOBP1 ($6c); the rest use rOBP0 + -- (= wAnimPalette during subanimations: $f0 on SGB, $e4 on DMG) + return { x = x, y = y, tile = t.tile, ts = tileset, xf = xf, yf = yf, + obp = t.pal1 and "obp1" or "f0" } +end + +-- Compile the move's battle_anim rows into a list of timed steps by +-- simulating the OAM buffer, so :update()/:draw() are trivial. +function AnimPlayer:start(moveId, attackerIsPlayer, opts) + self.steps, self.events = {}, {} + self.stepIndex, self.stepLeft = 1, 0 + self.elapsed, self.eventCursor = 0, 1 + + local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId] + if not anim then + self:warnOnce("move:" .. tostring(moveId), + "AnimPlayer: no animation data for move %s", tostring(moveId)) + return + end + + local steps, events = self.steps, self.events + local oam, oamMax = {}, 0 + local frame = 0 + -- DoGrowlSpecialEffects (:928): after every frame block it copies the + -- note sprite's 4 OAM entries to a second, untouched slot; since GROWL + -- also skips AnimationCleanOAM between blocks (the mode 0/1 branch + -- below), that copy from the PREVIOUS block is still on screen -- at + -- its old base coordinate -- while the current block draws, so two + -- notes are visible each frame, one trailing a step behind the other + local growlNoteTrail + + local function emit(dur, spritesOverride) + if dur < 1 then dur = 1 end + local sprites = spritesOverride + if not sprites then + sprites = {} + for i = 1, oamMax do + local s = oam[i] + if s then sprites[#sprites + 1] = s end + end + end + steps[#steps + 1] = { dur = dur, sprites = sprites } + frame = frame + dur + end + + -- AnimationFlashScreen (4 blocking frames), reused by the per-block + -- animation-id effects; same visual as an SE_DARK_SCREEN_FLASH row + local function flashScreen() + events[#events + 1] = { effect = "SE_DARK_SCREEN_FLASH", frame = frame } + emit(4) + end + + local idFx = ANIM_ID_FX[moveId] + + -- DoBallTossSpecialEffects (:685): while a Master or Ultra ball is + -- tossed (wCurItem <= ULTRA_BALL), the per-block special effect XORs + -- rOBP0 with %00111100, complementing colors 1 and 2 -- the ball + -- flickers between the $f0 and $cc shade maps block to block. The + -- effect runs AFTER each block displays, so block 1 shows normal. + -- PlayAnimation pushes rOBP0 around every subanimation row (:246-251 + -- / :259-262), so the ambient palette returns when the toss ends. + local ballFlicker = opts + and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL") + and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM" + or moveId == "ULTRATOSS_ANIM") + local obp0Flip = false + + for _, row in ipairs(anim.seq) do + -- PlayAnimation/PlaySubanimation: each row's sound byte is a move id + -- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as + -- the row starts (GetMoveSound) + if row.sound then + events[#events + 1] = { sound = row.sound, frame = frame } + end + if row.effect then + local emitter = EMITTERS[row.effect] + if emitter then + -- the emitter routines write OAM from slot 0 and clean up after + oam, oamMax = {}, 0 + local emSteps, tailFx = emitter(attackerIsPlayer) + events[#events + 1] = { effect = row.effect, frame = frame } + for _, st in ipairs(emSteps) do + emit(st.dur, st.sprites) + end + emit(1, {}) -- AnimationCleanOAM / ClearSprites + if tailFx == "flash" then flashScreen() end + else + local dur = SE_FRAMES[row.effect] + events[#events + 1] = { effect = row.effect, frame = frame, + dur = dur or SE_PAUSE_FRAMES } + if dur == nil then dur = SE_PAUSE_FRAMES end + if dur > 0 then emit(dur) end + end + else + local sub = self.data.subanims and self.data.subanims[row.subanim] + if not (sub and sub.blocks) then + self:warnOnce("subanim:" .. tostring(row.subanim), + "AnimPlayer: %s references unknown subanimation %s", + tostring(moveId), tostring(row.subanim)) + else + local transform = resolveTransform(sub.type, attackerIsPlayer) + local first, last, dir = 1, #sub.blocks, 1 + if transform == "REVERSE" then first, last, dir = last, first, -1 end + -- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK + -- and a 40-frame pause, then rewinds the same subanimation; the + -- mode-4 frame blocks persist, so the resting ball stays visible + -- through the pauses between wobbles + for _ = 1, (opts and opts.shakes) or 1 do + if opts and opts.shakes then + events[#events + 1] = { effect = "SFX_TINK", frame = frame } + emit(40) + end + local dest = 1 -- PlaySubanimation resets the OAM cursor per row + local nblocks = math.abs(last - first) + 1 + local played = 0 + for bi = first, last, dir do + local entry = sub.blocks[bi] + local fb = self.data.frameBlocks and self.data.frameBlocks[entry.block] + local bc = self.data.baseCoords and self.data.baseCoords[entry.coord] + if not (fb and bc) then + self:warnOnce("block:" .. tostring(entry.block) .. ":" .. tostring(entry.coord), + "AnimPlayer: %s references missing frame block/coord", + tostring(moveId)) + else + for j = 1, #fb do + oam[dest + j - 1] = placeTile(transform, bc, fb[j], row.tileset) + end + if obp0Flip then + -- rOBP0 is complemented right now: this block's rOBP0 + -- tiles show with colors 1/2 swapped ($f0 -> $cc) + for j = 1, #fb do + local t = oam[dest + j - 1] + if t.obp == "f0" then t.obp = "f0x" end + end + end + if dest + #fb - 1 > oamMax then oamMax = dest + #fb - 1 end + local mode = entry.mode + if mode == 2 then -- accumulate, no frame shown yet + dest = dest + #fb + elseif mode == 3 then -- show and persist + emit(row.delay) + dest = dest + #fb + elseif mode == 4 then -- show; next block overwrites + emit(row.delay) + else -- 0/1: show, then clean the OAM buffer + if moveId == "GROWL" then + -- GROWL quirk: sprites persist (no clean), plus the + -- previous block's note copy draws alongside this one + local current = {} + for i = 1, oamMax do + if oam[i] then current[#current + 1] = oam[i] end + end + local shown = current + if growlNoteTrail then + shown = {} + for _, s in ipairs(current) do shown[#shown + 1] = s end + for _, s in ipairs(growlNoteTrail) do shown[#shown + 1] = s end + end + emit(row.delay, shown) + growlNoteTrail = current + else + emit(row.delay + 1) -- AnimationCleanOAM's extra frame + oam, oamMax = {}, 0 + end + dest = 1 + end + -- DoSpecialEffectByAnimationId runs after every frame + -- block with wSubAnimCounter = blocks remaining + played = played + 1 + if ballFlicker then obp0Flip = not obp0Flip end + if idFx then + local counter = nblocks - played + 1 + if idFx == "flash" + or (idFx == "every4" and counter % 4 == 0) + or (idFx == "every8" and counter % 8 == 0) + or (idFx == "blizzard" and (counter == 13 or counter == 9 + or counter == 5 or counter == 1)) then + flashScreen() + elseif idFx == "explode" then + if counter % 4 == 0 then flashScreen() end + if counter == 1 then + -- DoExplodeSpecialEffects: the user's pic vanishes + events[#events + 1] = { effect = "SE_HIDE_ATTACKER_PIC", + frame = frame } + end + elseif idFx == "rockslide" then + if counter >= 8 and counter <= 11 then + -- 1px horizontal + vertical rumble (15 blocking frames) + events[#events + 1] = { effect = "SE_ROCK_SLIDE_SHAKE", + frame = frame, dur = 15 } + emit(15) + elseif counter == 1 then + flashScreen() + end + end + end + end + end + end + end + end + end + + local firstStep = steps[1] + self.stepLeft = firstStep and firstStep.dur or 0 +end + +-- Advance one frame (call once per 60fps tick). +function AnimPlayer:update() + if self:isDone() then return end + self.elapsed = self.elapsed + 1 + self.stepLeft = self.stepLeft - 1 + while self.stepLeft <= 0 do + self.stepIndex = self.stepIndex + 1 + local st = self.steps[self.stepIndex] + if not st then break end + self.stepLeft = st.dur + end +end + +function AnimPlayer:isDone() + return self.steps[self.stepIndex] == nil +end + +-- SE_* rows whose time has come since the last poll: +-- returns { { effect = "SE_...", frame = n }, ... } (possibly empty). +function AnimPlayer:pollEffects() + local fired = {} + local events = self.events + while self.eventCursor <= #events + and events[self.eventCursor].frame <= self.elapsed do + fired[#fired + 1] = events[self.eventCursor] + self.eventCursor = self.eventCursor + 1 + end + return fired +end + +function AnimPlayer:sheetImage(ts) + local cached = self.images[ts] + if cached ~= nil then + return cached or nil + end + local sheet = self.data.tilesheets and self.data.tilesheets[ts] + local ok, img = false, nil + if sheet and love and love.graphics and love.graphics.newImage then + ok, img = pcall(love.graphics.newImage, sheet.path) + end + if not (ok and img) then + self:warnOnce("sheet:" .. tostring(ts), + "AnimPlayer: battle anim tilesheet %s unavailable", + tostring(sheet and sheet.path or ts)) + img = false + end + self.images[ts] = img or false + return self.images[ts] or nil +end + +function AnimPlayer:tileQuad(ts, tile) + local sheet = self.data.tilesheets[ts] + if not sheet or tile >= sheet.tiles then return nil end + local perSheet = self.quads[ts] + if not perSheet then + perSheet = {} + self.quads[ts] = perSheet + end + local q = perSheet[tile] + if q == nil and love and love.graphics and love.graphics.newQuad then + local cols = math.floor(sheet.width / 8) + q = love.graphics.newQuad((tile % cols) * 8, + math.floor(tile / cols) * 8, + 8, 8, sheet.width, sheet.height) + perSheet[tile] = q + end + return q +end + +-- Draw the current frame's tiles onto the 160x144 battle canvas. +-- colorFn (optional): function(sprite, px, py) -> {c1,c2,c3} SGB colors +-- (0-1 RGB triples) for the sprite's three opaque shades at screen +-- pixel (px, py), or nil to draw the raw DMG grays. BattleState +-- supplies the SGB zone palette + OBJ palette mapping. +function AnimPlayer:draw(colorFn) + local st = self.steps[self.stepIndex] + if not st then return end + self:drawSprites(st.sprites, colorFn) +end + +-- The last compiled step's sprites, or nil. After a capture the +-- SHAKE_ANIM chain ends on the resting closed ball (its mode-4 frame +-- blocks persist), which the GB leaves in OAM through the caught text; +-- BattleState keeps drawing it via drawSprites. +function AnimPlayer:finalSprites() + local last = self.steps[#self.steps] + return last and last.sprites or nil +end + +-- two colorFn results (three 0-1 RGB triples) resolve the same palette? +local function sameColors(a, b) + if a == b then return true end + for i = 1, 3 do + local p, q = a[i], b[i] + if p[1] ~= q[1] or p[2] ~= q[2] or p[3] ~= q[3] then return false end + end + return true +end + +-- Draw one compiled step's OAM sprites. With colorFn, each sprite is +-- drawn through the PaletteFX shade-remap shader. The SGB colorized +-- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions +-- know nothing of OAM), so a tile overlapping a palette boundary shows +-- each region's colors on the pixels inside it: colorFn is sampled +-- once per attribute cell the 8x8 tile touches (up to 4), and cells +-- that resolve to a different palette than the first are repainted +-- through a scissor clipped to the cell. +function AnimPlayer:drawSprites(sprites, colorFn) + local g = love and love.graphics + local shader + if colorFn and g and g.setShader then + shader = require("src.render.PaletteFX").shader() + end + local slices = shader and g.getScissor and g.intersectScissor + and g.setScissor + for i = 1, #sprites do + local s = sprites[i] + -- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168); + -- wrapped offsets rely on this to park tiles offscreen + if s.x > 0 and s.x < 168 and s.y > 0 and s.y < 160 then + local img = self:sheetImage(s.ts) + local quad = img and self:tileQuad(s.ts, s.tile) + if quad then + local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile + local function blit() + g.draw(img, quad, + rx + (s.xf and 8 or 0), + ry + (s.yf and 8 or 0), + 0, + s.xf and -1 or 1, + s.yf and -1 or 1) + end + -- the attribute cell holding the tile's top-left pixel + local cx = math.floor(rx / 8) * 8 + local cy = math.floor(ry / 8) * 8 + local colors = shader and colorFn(s, cx, cy) + if colors then + g.setShader(shader) + -- c0 is the transparent color-0 slot; send anything + shader:send("c0", colors[1]) + shader:send("c1", colors[1]) + shader:send("c2", colors[2]) + shader:send("c3", colors[3]) + end + blit() + if colors and slices and (cx ~= rx or cy ~= ry) then + -- unaligned: the tile spills into up to 3 more cells; repaint + -- the ones whose zone palette differs (opaque overdraw -- GB + -- tiles have binary alpha) + local function slice(px, py) + if px < 0 or py < 0 or px >= 160 or py >= 144 then + return -- fully off-canvas + end + local cc = colorFn(s, px, py) + if not cc or sameColors(cc, colors) then return end + local s1, s2, s3, s4 = g.getScissor() + g.intersectScissor(px, py, 8, 8) + shader:send("c0", cc[1]) + shader:send("c1", cc[1]) + shader:send("c2", cc[2]) + shader:send("c3", cc[3]) + blit() + if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end + end + local cx2 = math.floor((rx + 7) / 8) * 8 + local cy2 = math.floor((ry + 7) / 8) * 8 + if cx2 ~= cx then slice(cx2, cy) end + if cy2 ~= cy then slice(cx, cy2) end + if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end + end + if colors then + g.setShader() + end + end + end + end +end + +return AnimPlayer diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua new file mode 100644 index 00000000..08204d29 --- /dev/null +++ b/src/battle/BattleState.lua @@ -0,0 +1,3832 @@ +-- The battle state: wild and trainer battles driven entirely by generated +-- data (species, moves, type chart, trainer parties, encounter tables). +-- +-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn +-- resolution (a queue of messages/actions/UI pushes) -> back to menu, +-- until one side is out, then finish. Pops itself and calls +-- onFinish("win"|"lose"|"run"|"caught"|"skipped"). +-- +-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash, +-- bide, recharge, confusion, screens, substitute, transform, ...) is +-- ported from engine/battle/core.asm; see docs/behavior-porting-notes.md. + +local Catching = require("src.battle.Catching") +local Damage = require("src.battle.Damage") +local Experience = require("src.battle.Experience") +local Font = require("src.render.Font") +local Logger = require("src.core.Logger") +local MoveEffects = require("src.battle.MoveEffects") +local Party = require("src.pokemon.Party") +local Pokemon = require("src.pokemon.Pokemon") +local Status = require("src.battle.Status") +local TrainerAI = require("src.battle.TrainerAI") +local TurnOrder = require("src.battle.TurnOrder") +local TypeChart = require("src.battle.TypeChart") + +local BattleState = {} +BattleState.__index = BattleState +BattleState.isOpaque = true + +-- Battle colors itself per-pixel (species pics + HP bar tints), so the +-- SGB whole-screen remap must not run over it. +function BattleState.sgbPalettes() return nil end + +local Rulesets = { + gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + modern_clean = require("src.battle.rulesets.modern_clean"), +} + +-- the Poké Ball toss chain (TossBallAnimation) plays even with battle +-- animations off: PlayMoveAnimation jumps to it before checking wOptions +local BALL_ANIMS = { + TOSS_ANIM = true, GREATTOSS_ANIM = true, ULTRATOSS_ANIM = true, + BLOCKBALL_ANIM = true, POOF_ANIM = true, HIDEPIC_ANIM = true, + SHAKE_ANIM = true, SHOWPIC_ANIM = true, +} + +local imageCache = {} +-- fully transparent rows below a pic's content (the extracted 32x32 back +-- pics carry baked-in padding); used to sit the pic flush on the text box +local imagePadBottom = {} +-- image -> { path, pal } so palette-fade variants (see fadeImage) can be +-- rebuilt for any battle pic, whatever code loaded it +local imageMeta = {} +-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy +local function getImage(path, pal) + if not path then return nil end + local key = pal and (path .. "#" .. pal.name) or path + if not imageCache[key] then + local img, pad = nil, 0 + if love.image and love.image.newImageData then + local id = love.image.newImageData(path) + if pal then + local c = pal.colors + id:mapPixel(function(_, _, r, g, b, a) + if a == 0 then return r, g, b, a end + local col = r > 0.83 and c[1] or r > 0.5 and c[2] + or r > 0.17 and c[3] or c[4] + return col[1] / 255, col[2] / 255, col[3] / 255, a + end) + end + local w, h = id:getDimensions() + local bottom = h - 1 + while bottom >= 0 do + local opaque = false + for x = 0, w - 1 do + local _, _, _, a = id:getPixel(x, bottom) + if a > 0 then opaque = true break end + end + if opaque then break end + bottom = bottom - 1 + end + img = love.graphics.newImage(id) + pad = h - 1 - bottom + else + img = love.graphics.newImage(path) -- headless stub: no pixel access + end + imageCache[key] = img + imagePadBottom[img] = pad + imageMeta[img] = { path = path, pal = pal } + end + return imageCache[key] +end + +-- the species' SGB palette (data/pokemon/palettes.asm), or nil +local function monPalette(data, species) + local p = data.palettes + local name = p and p.pokemon[species] + local colors = name and p.palettes[name] + return colors and { name = name, colors = colors } or nil +end + +-- a named palette from data/generated/palettes.lua as a getImage pal +local function namedPalette(data, name) + local p = data.palettes + local colors = p and p.palettes[name] + return colors and { name = name, colors = colors } or nil +end + +-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the +-- SetAnimationBGPalette effects remap the four BG shades; on the SGB +-- the colorizer then colors the REMAPPED shade, so a faded pic shows +-- palette[bgp[shade]]). bgp = shade map {[0..3] -> 0..3} or nil. +local function fadeImage(img, bgp) + if not bgp or not img then return img end + local meta = imageMeta[img] + if not meta then return img end + local PaletteFX = require("src.render.PaletteFX") + local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS + local name = (meta.pal and meta.pal.name or "GB") + .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] + return getImage(meta.path, + { name = name, colors = PaletteFX.permute(base, bgp) }) +end + +-- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the +-- pics into the BG canvas so they wave with it; the zone pass then +-- colors them by region like the real SGB) +local function grayImage(img) + local meta = imageMeta[img] + if not meta or not meta.pal then return img end + return getImage(meta.path) or img +end + +-- the image a battler pic actually draws with this frame +function BattleState:picImage(img) + if self.grayPics then return grayImage(img) end + return fadeImage(img, self:activeBgp()) +end + +-- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm) +local TRAINER_DVS = { attack = 9, defense = 8, speed = 8, special = 8, hp = 8 } + +-- Status-move effects whose pokered handlers call MoveHitTest (sleep/ +-- poison/paralyze/confusion/leech seed/disable and the primary +-- stat-down moves). Everything else in MoveEffects.primary is +-- self-targeting and never rolls accuracy. Mimic also hit-tests but +-- runs its own mid-move flow (resolveMimic). +local ACC_CHECKED_STATUS = { + SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true, + CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true, + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, + DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, + ACCURACY_DOWN1_EFFECT = true, +} + +-- pokered's / text macros (home/text.asm +-- PlaceMoveUsersName): battle texts naming the enemy mon print +-- "Enemy " before the nickname; player-side mons never get it. +local function displayName(b) + return b.isPlayer and b.name or ("Enemy " .. b.name) +end + +-- Apply the "Enemy " prefix to a pre-built message from a module that +-- only knows the raw nickname (Status.beforeMove/residual, +-- TrainerAI.useItem): splice it in before the first name occurrence. +local function prefixEnemy(msg, battler) + if battler.isPlayer then return msg end + local s = msg:find(battler.name, 1, true) + if not s then return msg end + return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s) +end + +-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2) +-- 11x10 over the battle, dismissed with A/B) +local StatBox = {} +StatBox.__index = StatBox + +function StatBox.new(game, mon, onDone) + return setmetatable({ game = game, mon = mon, onDone = onDone }, StatBox) +end + +function StatBox:update() + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +function StatBox:draw() + Font.drawBox(9, 2, 11, 10) + love.graphics.setColor(0, 0, 0, 1) + local s = self.mon.stats + local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense }, + { "SPEED", s.speed }, { "SPECIAL", s.special } } + for i, r in ipairs(rows) do + Font.draw(r[1], 88, 24 + (i - 1) * 16) + Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16) + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- --------------------------------------------------------------------- +-- construction +-- --------------------------------------------------------------------- + +local function makeBattler(data, mon, isPlayer, save) + local def = data.pokemon[mon.species] + local badges = nil + if isPlayer and save then + -- Gen 1 badge stat boosts (x9/8) + badges = {} + for _, b in ipairs({ "BOULDERBADGE", "THUNDERBADGE", "SOULBADGE", "VOLCANOBADGE" }) do + if save.inventory[b] then badges[b] = true end + end + end + return { + mon = mon, + def = def, + name = mon.nickname or def.name, + isPlayer = isPlayer, + badges = badges, + shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) + stages = {}, + -- volatile state; Transform/Conversion/Mimic override the cur* fields + curStats = mon.stats, + curTypes = def.types, + curMoves = mon.moves, + sprite = getImage(isPlayer and def.spriteBack or def.spriteFront, + monPalette(data, mon.species)), + } +end + +-- The battle pic for `species` on the given side (back pic for the +-- player side, front pic for the enemy side), tinted PAL_GRAYMON -- +-- the same path makeBattler uses, but forced gray -- since this is only +-- ever used for a Transformed mon's swapped-in pic (transform.asm:31-53 +-- AnimationTransformMon; the SGB color comes from DeterminePaletteID, +-- which forces PAL_GRAYMON for a Transformed mon rather than the copied +-- species' own palette). +function BattleState:speciesSprite(species, isPlayerSide) + local def = self.data.pokemon[species] + if not def then return nil end + local PaletteFX = require("src.render.PaletteFX") + local colors = PaletteFX.monPal(self.data, species, true) + return getImage(isPlayerSide and def.spriteBack or def.spriteFront, + colors and { name = "GRAYMON", colors = colors } or nil) +end + +local function markSeen(game, species) + local dex = game.save.pokedex + if dex then dex.seen[species] = true end +end + +-- newly obtained mons carry the player's OT name/ID (status screen) +local function stampOT(save, mon) + save.player.id = save.player.id or math.random(0, 65535) + mon.ot = mon.ot or save.player.name + mon.otId = mon.otId or save.player.id +end +BattleState.stampOT = stampOT + +local function markOwned(game, species) + local dex = game.save.pokedex + if dex then + dex.seen[species] = true + if not dex.owned[species] then + -- new dex page registered (SFX_DEX_PAGE_ADDED) + require("src.core.Sound").play(game.data, "Dex_Page_Added") + end + dex.owned[species] = true + end +end +BattleState.markOwned = markOwned +BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox) + +local function newBattle(game) + local self = setmetatable({}, BattleState) + self.game = game + self.data = game.data + self.ruleset = Rulesets[game.save.options and game.save.options.ruleset or "gen1_faithful"] + or Rulesets.gen1_faithful + self.rng = function(a, b) return love.math.random(a, b) end + TypeChart.load(game.data) + -- the subanimation player (data/battle_anims via battle_anims.lua) + if game.data.battle_anims then + local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") + if ok then + self.animPlayer = AnimPlayer.new(game.data.battle_anims) + end + end + self.queue = {} + self.phase = "intro" + self.menuIndex = 1 + self.moveIndex = 1 + self.frame = 0 + return self +end + +-- opts.hooked: rod encounter, announced with _HookedMonAttackedText +function BattleState.newWild(game, species, level, opts) + local self = newBattle(game) + self.kind = "wild" + local playerMon = Party.firstHealthy(game.save.party) + if not playerMon then + Logger.warn("wild battle with no healthy party; skipping") + self.dead = true + else + self.player = makeBattler(game.data, playerMon, true, game.save) + end + self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false) + markSeen(game, species) + if opts and opts.hooked then + self.introText = ("The hooked\n%s\nattacked!"):format(self.enemy.name) + else + self.introText = ("Wild %s\nappeared!"):format(self.enemy.name) + end + return self +end + +-- data/trainers/special_moves.asm + read_trainer_party.asm: boss move +-- overrides, always written into the mon's THIRD move slot. +-- LoneMoves: the gym scripts write the gym number to wGymLeaderNo, so +-- these fire only for the leaders' gym battles (Giovanni: party 3); +-- the table's "index n" lands on the (n+1)-th party mon via AddNTimes. +-- TeamMoves: despite the "whole team" comment, the code writes only +-- wEnemyMon5Moves+2, the FIFTH mon of each Elite Four member. +-- RIVAL3 (Champion): Pidgeot gets SKY ATTACK, the starter's final +-- form gets MEGA DRAIN / FIRE BLAST / BLIZZARD. +local LONE_MOVES = { + OPP_BROCK = { 2, "BIDE" }, + OPP_MISTY = { 2, "BUBBLEBEAM" }, + OPP_LT_SURGE = { 3, "THUNDERBOLT" }, + OPP_ERIKA = { 3, "MEGA_DRAIN" }, + OPP_KOGA = { 4, "TOXIC" }, + OPP_SABRINA = { 4, "PSYWAVE" }, + OPP_BLAINE = { 4, "FIRE_BLAST" }, + OPP_GIOVANNI = { 5, "FISSURE", onlyParty = 3 }, +} +local TEAM_MOVES = { + OPP_LORELEI = "BLIZZARD", OPP_BRUNO = "FISSURE", + OPP_AGATHA = "TOXIC", OPP_LANCE = "BARRIER", +} +local RIVAL_STARTER_MOVES = { + VENUSAUR = "MEGA_DRAIN", CHARIZARD = "FIRE_BLAST", BLASTOISE = "BLIZZARD", +} + +local function setThirdMove(data, mon, moveId) + if not mon then return end + local mdef = data.moves[moveId] + local entry = { id = moveId, pp = mdef and mdef.pp or 0 } + mon.moves[math.min(3, #mon.moves + 1)] = entry +end + +local function applySpecialMoves(data, oppClass, partyIndex, party) + local lone = LONE_MOVES[oppClass] + if lone and (not lone.onlyParty or lone.onlyParty == partyIndex) then + setThirdMove(data, party[lone[1]], lone[2]) + return + end + local team = TEAM_MOVES[oppClass] + if team then + setThirdMove(data, party[5], team) + return + end + if oppClass == "OPP_RIVAL3" then + setThirdMove(data, party[1], "SKY_ATTACK") + local starter = party[6] + if starter and RIVAL_STARTER_MOVES[starter.species] then + setThirdMove(data, starter, RIVAL_STARTER_MOVES[starter.species]) + end + end +end + +function BattleState.newTrainer(game, oppClass, partyIndex) + local self = newBattle(game) + self.kind = "trainer" + self.trainer = game.data.trainers[oppClass] + assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) + self.enemyAIMods = self.trainer.aiMods + local partyDef = self.trainer.parties[partyIndex or 1] + assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex))) + self.enemyParty = {} + for _, slot in ipairs(partyDef) do + local mon = Pokemon.new(game.data, slot.species, slot.level) + -- fixed trainer DVs, recomputed stats + mon.dvs = TRAINER_DVS + mon.stats = require("src.pokemon.Stats").calc(game.data.pokemon[slot.species], + slot.level, TRAINER_DVS) + mon.hp = mon.stats.hp + table.insert(self.enemyParty, mon) + end + applySpecialMoves(game.data, oppClass, partyIndex or 1, self.enemyParty) + self.enemyIndex = 1 + local playerMon = Party.firstHealthy(game.save.party) + if not playerMon then + Logger.warn("trainer battle with no healthy party; skipping") + self.dead = true + else + self.player = makeBattler(game.data, playerMon, true, game.save) + end + self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon + markSeen(game, self.enemyParty[1].species) + -- SGB: the enemy-side battle palette while the trainer pic is up is + -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes + -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE + -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) + self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) + self.introText = ("%s wants\nto fight!"):format(self.trainer.name) + return self +end + +-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope +-- the enemy is "GHOST", you're too scared to attack, and balls fail. +function BattleState:makeGhost() + self.ghost = true + self.enemy.name = "GHOST" + -- the ghost keeps the disguised mon's SGB palette: InitWildBattle + -- swaps only the pic, wEnemyMonSpecies2 still holds the real species + -- (engine/battle/core.asm InitWildBattle .isGhost) + self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", + monPalette(self.data, self.enemy.mon.species)) + self.introText = "The GHOST\nappeared!" +end + +-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN, +-- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no +-- player mon; the battle menu appears under the OLD MAN's name and a +-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu +-- (one POKé BALL x50). The throw always catches; nothing is kept. +function BattleState:makeOldManDemo() + self.demo = true +end + +-- Safari Zone battles (engine/battle/core.asm safari sections + +-- engine/battle/safari_zone.asm): no player mon acts; the menu is +-- BALL / BAIT / ROCK / RUN. state is save.safari ({balls, steps}). +function BattleState:makeSafari(state) + self.safari = state + self.safariCatchRate = self.enemy.def.catchRate + self.baitFactor = 0 + self.escapeFactor = 0 +end + +-- --------------------------------------------------------------------- +-- message/action queue +-- --------------------------------------------------------------------- + +function BattleState:say(text) + table.insert(self.queue, { text = text }) +end + +function BattleState:act(fn) + table.insert(self.queue, { fn = fn }) +end + +-- push a UI state above the battle; the queue pauses until it pops +function BattleState:ui(factory) + table.insert(self.queue, { ui = factory }) +end + +-- insert an animation row right after the current queue item (the +-- POOF/ball-toss animations past the move table); `shakes` marks the +-- ball-shake row with its wNumShakes repeat count, `ball` marks a toss +-- row with the thrown ball item (wCurItem -- a Master/Ultra toss +-- flickers the OBJ palette, DoBallTossSpecialEffects) +function BattleState:animNext(name, isPlayer, shakes, ball) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { anim = name, attackerIsPlayer = isPlayer, shakes = shakes, + ball = ball }) +end + +-- insert an act right after the current queue item +function BattleState:actNext(fn) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { fn = fn }) +end + +-- insert message right after the currently-executing queue item (the +-- counter is reset by updateQueue before each fn item runs) +function BattleState:sayNext(text) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { text = text }) +end + +-- insert a UI push right after the current queue item (dex page, the +-- level-up stat box -- anything that must keep queue order) +function BattleState:uiNext(factory) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { ui = factory }) +end + +-- insert a wait for the HP bars to finish draining (UpdateHPBar): +-- the queue holds until every battler's displayed HP catches up +function BattleState:drainNext() + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { drain = true }) +end + +-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar): +-- the bar animates a pixel per two frames, so displayed HP moves at +-- maxHP/96 per frame (48-pixel bar). Returns true while animating. +function BattleState:stepHPDrain() + local busy = false + for _, b in ipairs({ self.player, self.enemy }) do + if b and b.shownHP and b.shownHP ~= b.mon.hp then + local step = math.max(1, b.mon.stats.hp) / 96 + if b.shownHP > b.mon.hp then + b.shownHP = math.max(b.mon.hp, b.shownHP - step) + else + b.shownHP = math.min(b.mon.hp, b.shownHP + step) + end + busy = busy or b.shownHP ~= b.mon.hp + end + end + return busy +end + +-- the integer HP the HUD shows for a battler (whole HP ticks, like +-- UpdateHPBar's 1-HP steps) +local function shownHP(b) + local shown = b.shownHP or b.mon.hp + if shown > b.mon.hp then return math.ceil(shown) end + return math.floor(shown) +end + +function BattleState:startMessage(item) + self.current = item + self.lines = {} + self.total = 0 + for chunk in (item.text .. "\n"):gmatch("(.-)\n") do + local codes = Font.encode(chunk) + table.insert(self.lines, codes) + self.total = self.total + #codes + end + self.charIndex = 0 + self.holdTimer = nil +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + -- a queued hold (faint slide, hit blink) counts down before the next row + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + -- an HP-bar drain holds the queue until the bar catches up + if self.draining then + if self:stepHPDrain() then return true end + self.draining = nil + end + -- a move animation holds the queue until it finishes; its screen + -- effects (SE_*) and per-row sounds route into the fx layer as they + -- fire (applyAnimEffect implements each AnimationXXX routine) + if self.animPlaying then + self.animPlayer:update() + if self.animPlayer.pollEffects and self.applyAnimEffect then + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) + end + end + if self.animPlayer:isDone() then + self.animPlaying = false + -- the target's hit blink + damage sound follow the animation + -- (pokered plays them after PlayMoveAnimation returns) + if self.pendingHit then + self:applyHitFx(self.pendingHit) + self.pendingHit = nil + end + end + return true + end + if not self.current then + local item = table.remove(self.queue, 1) + if not item then return false end + if item.fn then + self.nextInsert = 0 -- sayNext inserts right after this item + item.fn() + self.current = nil + return true + end + if item.ui then + self.waitingUI = true + self.game.stack:push(item.ui()) + return true + end + if item.drain then + self.draining = true + return true + end + if item.wait then + self.waitFrames = item.wait + return true + end + if item.mimicSelect then + -- pause the queue on Mimic's copy menu (MoveSelectionMenu with + -- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1) + local ctx = item.mimicSelect + local rows = {} + for i, m in ipairs(ctx.target.curMoves) do + if m.id and m.pp ~= nil then rows[#rows + 1] = { slot = i, id = m.id } end + end + self.mimicMoves = rows + self.mimicIndex = 1 + self.mimicCtx = ctx + self.phase = "mimicSelect" + return true + end + -- animation queue rows: play the move sound and start the + -- subanimation (or just the coarse fx when animations are off). + -- item.hit carries the target's blink + damage sound, applied when + -- the animation ends (hitRow rows carry a hit with no animation -- + -- thrash/rage continuation turns that skip the announcement). + if item.anim or item.hitRow then + local mdef = item.anim and self.data.moves[item.anim] + local anim = mdef and mdef.anim + if item.anim == "POOF_ANIM" then + -- the send-out poof plays SFX_BALL_POOF + require("src.core.Sound").play(self.data, "Ball_Poof") + elseif item.anim == "HIDEPIC_ANIM" then + self.enemyHidden = true -- SE_HIDE_ENEMY_MON_PIC + elseif item.anim == "SHOWPIC_ANIM" then + self.enemyHidden = false -- SE_SHOW_ENEMY_MON_PIC + end + -- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation + -- short-circuits to TossBallAnimation before its wOptions check + -- (engine/battle/animations.asm:415) + if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then + if self.animPlayer then + local ok = pcall(self.animPlayer.start, self.animPlayer, + item.anim, item.attackerIsPlayer, + (item.shakes or item.ball) + and { shakes = item.shakes, ball = item.ball } + or nil) + self.animPlaying = ok + end + self.fx = self.fx or {} + if anim and anim.shake and not self.animPlaying then self.fx.shake = 24 end + if anim and anim.flash and not self.animPlaying then self.fx.flash = 16 end + end + if self.animPlaying then + -- the animation rows carry their own sounds (PlayAnimation + -- plays each row's MoveSoundTable entry with its pitch/tempo + -- modifiers); which side the pic effects target follows the + -- attacker (hWhoseTurn) + self.animName = item.anim + self.animAttackerIsPlayer = item.attackerIsPlayer + self:resetPicFx() + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + end + self.pendingHit = item.hit + else + -- no subanimation player: keep the single-sound fallback (with + -- the move's pitch/tempo modifiers; GROWL/ROAR play the + -- attacker's cry -- GetMoveSound/IsCryMove) + if item.anim == "GROWL" or item.anim == "ROAR" then + local attacker = item.attackerIsPlayer and self.player or self.enemy + if attacker then + require("src.core.Sound").playMoveCry(self.data, attacker.mon.species, + anim and anim.tempo) + end + elseif anim and anim.sound then + local Sound = require("src.core.Sound") + if Sound.playMove then + Sound.playMove(self.data, anim) + else + Sound.play(self.data, anim.sound) + end + end + if item.hit then + self:applyHitFx(item.hit) + end + end + self.current = nil + return true + end + self:startMessage(item) + end + if self.charIndex < self.total then + self.charIndex = math.min(self.total, self.charIndex + 2) + else + self.holdTimer = (self.holdTimer or 40) - 1 + local input = self.game.input + if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then + self.current = nil + end + end + return true +end + +-- --------------------------------------------------------------------- +-- update / menus +-- --------------------------------------------------------------------- + +-- PrintSendOutMonMessage (engine/battle/common_text.asm): the shout +-- scales with the enemy's remaining HP percentage, approximated as +-- curHP * 25 / (maxHP / 4): >=70 "Go!", 40-69 "Do it!", 10-39 +-- "Get'm!", below 10 "The enemy's weak! Get'm!". +function BattleState:sendOutText(name) + local e = self.enemy and self.enemy.mon + local pct = 100 + if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then + pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4)) + end + if pct >= 70 then return ("Go! %s!"):format(name) end + if pct >= 40 then return ("Do it! %s!"):format(name) end + if pct >= 10 then return ("Get'm! %s!"):format(name) end + return ("The enemy's weak!\nGet'm! %s!"):format(name) +end + +-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the +-- gym-leader theme, Lance does too, and the Champion (OPP_RIVAL3) +-- gets the final-battle theme +function BattleState:computeMusicKind() + local isBoss = false + if self.kind == "trainer" and self.trainer then + local victories = require("data.scripts.victories") + for key, reward in pairs(victories) do + if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then + isBoss = true + break + end + end + end + if self.kind == "trainer" and self.trainer + and self.trainer.id == "OPP_RIVAL3" then + return "final" + elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then + return "gym" + elseif self.kind == "trainer" then + return "trainer" + end + return "wild" +end + +function BattleState:enter() + if self.dead then + self.game.stack:pop() + if self.onFinish then self.onFinish("skipped") end + return + end + local Music = require("src.core.Music") + self.musicKind = self:computeMusicKind() + -- normally already playing: the transition wipe starts the theme + -- (audio/play_battle_music.asm runs before the transition, and + -- Music.play no-ops on the same song); this covers battles pushed + -- without a transition (link battles, scripted pushes) + Music.playBattle(self.data, self.musicKind) + -- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both + -- sides slide in; the trainer pics stay up until the send-outs + self.introSlide = 40 + self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil + -- SGB: the player-side battle palette while the back pic is up is + -- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when + -- the intro's SET_PAL_BATTLE runs -- SetPal_Battle, + -- engine/gfx/palettes.asm:28) + self.playerBackPic = getImage(self.demo + and "assets/generated/battle/oldmanb.png" + or "assets/generated/battle/redb.png", + namedPalette(self.data, "MEWMON")) + self.showPlayerBack = self.playerBackPic ~= nil + self:say(self.introText) + if self.kind == "trainer" then + self:say(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + self:act(function() + -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the + -- pic grows out of the ball (AnimateSendingOutMon), then the cry + self.showEnemyTrainer = false + self:startGrowIn(self.enemy) + end) + end + if not self.ghost then + -- the enemy's cry plays as it appears (data/pokemon/cries.asm) + self:act(function() + require("src.core.Sound").playCry(self.data, self.enemy.mon.species) + end) + end + if not self.safari and not self.demo then + self:say(self:sendOutText(self.player.name)) + -- Red's pic clears, the POOF plays, then the mon appears with its + -- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) + self:act(function() + self.showPlayerBack = false + self.sendingOut = true + end) + table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + self:act(function() + self.sendingOut = false + -- SendOutMon (core.asm:1757-1762): after the poof the mon grows + -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + self:markParticipant() + end + self.phase = "messages" + self.afterQueue = "menu" +end + +-- any pop (finish, script teardown) must silence the alarm loop +-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) +function BattleState:exit() + require("src.core.Sound").stopLoop("Low_Health_Alarm") +end + +-- An action the battler is locked into (bypasses the menu), or nil. +function BattleState:lockedAction(battler) + if battler.mustRecharge then return { special = "recharge" } end + if battler.charging then return battler.charging end + if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end + if battler.trappingTurns and battler.trappingTurns > 0 then + return { special = "trapping" } + end + if battler.bideTurns then return { special = "bide" } end + if battler.rageMove then return battler.rageMove end + -- held in place while the OPPONENT's trapping move is running + -- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a + -- trap ended early by paralysis/faint frees the victim immediately); + -- boundTurns is a mirror kept for Status.beforeMove's held check + local opp = battler.isPlayer and self.enemy or self.player + battler.boundTurns = opp and opp.trappingTurns + and math.max(1, opp.trappingTurns) or nil + if battler.boundTurns then + return { special = "bound" } + end + return nil +end + +function BattleState:playerHasPP() + for i, mv in ipairs(self.player.curMoves) do + if mv.pp > 0 and self.player.disabledSlot ~= i then return true end + end + return false +end + +function BattleState:update(dt) + self.frame = self.frame + 1 + self:updateFx() + local input = self.game.input + + -- safety net: HP changed outside a queued drain (level-up heals, + -- field effects) snaps once the queue is idle + if self.phase == "menu" then + for _, b in ipairs({ self.player, self.enemy }) do + if b and b.shownHP then b.shownHP = b.mon.hp end + end + end + + if self.phase == "messages" then + if not self:updateQueue() then + if self.afterQueue == "menu" then + self.phase = "menu" + elseif self.afterQueue == "finish" then + self:finish() + end + end + return + end + + if self.phase == "menu" and self.demo then + -- DisplayBattleMenu's old-man branch (core.asm:2018-2050): input is + -- never read. The player name is swapped to OLD MAN, then the + -- keystrokes are simulated on screen -- the '▶' cursor sits next to + -- FIGHT (9,14) for 80 frames, hops down to ITEM (9,16) for 50, goes + -- hollow ('▷') and the ITEM menu is forced (a = $2 -> + -- .upperLeftMenuItemWasNotSelected). The old man never attacks; + -- backing out of the ball menu re-enters DisplayBattleMenu, which + -- replays the whole script. + self.demoTimer = (self.demoTimer or 0) + 1 + if self.demoTimer > 130 then + self.demoTimer = nil + self:openOldManBag() + end + return + end + + if self.phase == "menu" and self.safari then + if self.safari.balls <= 0 then + self:say("PA: You're out of\nSAFARI BALLs!\nGame over!") + self.phase = "messages" + self.result = "run" + self.afterQueue = "finish" + return + end + local col = (self.menuIndex - 1) % 2 + local row = math.floor((self.menuIndex - 1) / 2) + if input:wasPressed("left") or input:wasPressed("right") then + col = 1 - col + elseif input:wasPressed("up") or input:wasPressed("down") then + row = 1 - row + end + self.menuIndex = row * 2 + col + 1 + if input:wasPressed("a") then + self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + end + return + end + + if self.phase == "menu" then + -- forced replacement after a faint: ChooseNextMon (core.asm:1086) + -- loops the party menu until a healthy mon is picked, so B and + -- fainted picks land back here and reopen it + if self.player.mon.hp <= 0 then + if Party.firstHealthy(self.game.save.party) then + self:openReplacementMenu() + end + return + end + -- core.asm:297-300: both sides' FLINCHED bits are cleared during + -- move selection, but the clear is skipped while the player must + -- recharge or is locked into Rage (core.asm:293-295 -- the Hyper + -- Beam flinch-recharge glitch) + if not (self.player.mustRecharge or self.player.rageMove) then + self.player.flinched, self.enemy.flinched = false, false + end + -- locked multi-turn actions skip the menu entirely + local locked = self:lockedAction(self.player) + if locked then + self:resolveTurn(locked) + return + end + local col = (self.menuIndex - 1) % 2 + local row = math.floor((self.menuIndex - 1) / 2) + if input:wasPressed("left") or input:wasPressed("right") then + col = 1 - col + elseif input:wasPressed("up") or input:wasPressed("down") then + row = 1 - row + end + self.menuIndex = row * 2 + col + 1 + if input:wasPressed("a") then + local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] + if choice == "fight" and self.ghost then + self:say(("%s is too\nscared to move!"):format(self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) + elseif choice == "fight" then + if not self:playerHasPP() then + -- _NoMovesLeftText, then Struggle engages + self:say(("%s has no\nmoves left!"):format(self.player.name)) + self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true }) + return + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #self.player.curMoves) + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + end + return + end + + if self.phase == "moveSelect" then + local moves = self.player.curMoves + if input:wasPressed("up") then + self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves + elseif input:wasPressed("down") then + self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 + elseif input:wasPressed("b") then + self.phase = "menu" + elseif input:wasPressed("a") then + local mv = moves[self.moveIndex] + if self.player.disabledSlot == self.moveIndex then + self:say("The move is\ndisabled!") + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say("No PP left for\nthis move!") + self.phase = "messages" + self.afterQueue = "menu" + else + self:resolveTurn(mv) + end + end + return + end + + -- Mimic's mid-move copy menu (MimicEffect .letPlayerChooseMove, + -- effects.asm:1243-1260): opened by the queue AFTER the hit test + -- passes. MoveSelectionMenu's mimic type watches only UP/DOWN/A + -- (core.asm:2553-2557), so there is no backing out with B. + if self.phase == "mimicSelect" then + local moves = self.mimicMoves + if input:wasPressed("up") then + self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves + elseif input:wasPressed("down") then + self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 + elseif input:wasPressed("a") then + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + end + return + end +end + +-- MimicEffect (engine/battle/effects.asm:1203-1273) runs MID-move: a +-- 50-frame beat, MoveHitTest, and only on a hit does the player's copy +-- menu open (.letPlayerChooseMove). The enemy's Mimic -- and either +-- side of a link battle -- copies a RANDOM non-empty slot instead +-- (.getRandomMove). Both failure paths (accuracy roll, mid-Fly/Dig +-- target) print PrintButItFailedText_ and skip the move animation. +function BattleState:resolveMimic(user, target, move, moveInst) + -- ld c, 50 / call DelayFrames before anything happens + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 50 }) + if target.invulnerable + or not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng) then + self:sayNext("But, it failed!") + return + end + local slots = {} + for i, m in ipairs(target.curMoves) do + if m.id and m.pp ~= nil then slots[#slots + 1] = i end + end + if #slots == 0 then + -- .getRandomMove rerolls empty slots forever; a moveless target + -- can't happen in practice, so just fail instead of hanging + self:sayNext("But, it failed!") + return + end + if user.isPlayer and self.kind ~= "link" then + if self.mimicChoice then -- test-injection hook for the player pick + local slot = self:mimicChoice(target) + if slot and target.curMoves[slot] and target.curMoves[slot].pp ~= nil then + self:applyMimic(user, target, moveInst, slot) + return + end + end + -- pause the queue on a chooser row; the mimicSelect phase applies + -- the pick and resumes + self.nextInsert = self.nextInsert + 1 + table.insert(self.queue, self.nextInsert, { + mimicSelect = { user = user, target = target, moveInst = moveInst }, + }) + return + end + self:applyMimic(user, target, moveInst, slots[self.rng(1, #slots)]) +end + +-- The copied move OVERWRITES the used slot's move id in place; the PP +-- byte is untouched (only wBattleMonMoves is written, effects.asm: +-- 1261-1266), so the copy inherits Mimic's remaining PP and keeps +-- draining that same slot (DecrementPP hits both the battle copy and +-- the party struct). curMoves aliases mon.moves, so the original id is +-- remembered and restored when the battler leaves play -- pokered never +-- writes the party copy, and the battle copy is rebuilt from it on a +-- switch or at battle end. Then PlayCurrentMoveAnimation and +-- _MimicLearnedMoveText. +function BattleState:applyMimic(user, target, moveInst, slot) + local src = target.curMoves[slot] + if not (src and src.id) then return end + local mySlot + for i, m in ipairs(user.curMoves) do + if m == moveInst then mySlot = i break end + end + if not mySlot then + -- a called Mimic (Metronome) isn't in the list. For the player, + -- non-link case, MimicEffect snapshots wCurrentMenuItem BEFORE the + -- copy-picker menu opens and restores it afterward as the write + -- index (effects.asm:1247, 1256/1261) -- it reuses whatever slot + -- was left highlighted by the FIGHT menu. That's provably always + -- the calling move's own slot (e.g. METRONOME's): selecting a move + -- syncs wCurrentMenuItem and wPlayerMoveListIndex together + -- (core.asm's SelectMenuItem), and nothing touches either before + -- the effect runs. self.moveIndex mirrors this exactly -- it's + -- frozen at the FIGHT-menu confirm and untouched through mid-move + -- resolution -- so it already equals the calling move's slot here; + -- there's no separate "reused index" to chase. (Enemy/link Mimic + -- instead reads w*MoveListIndex directly, effects.asm:1235-1241.) + mySlot = user.isPlayer and math.min(self.moveIndex or 1, #user.curMoves) or 1 + end + local entry = user.curMoves[mySlot] + self.mimicRestores = self.mimicRestores or {} + table.insert(self.mimicRestores, { battler = user, entry = entry, id = entry.id }) + entry.id = src.id + entry.mimic = true + self:animNext("MIMIC", user.isPlayer) + -- _MimicLearnedMoveText: " / learned / MOVE!" + self:sayNext(("%s\nlearned\n%s!"):format(displayName(user), + self.data.moves[src.id].name)) +end + +-- Undo Mimic's in-place id overwrite for a battler leaving play (the GB +-- battle copy is discarded; the party struct never changed). +function BattleState:restoreMimicked(battler) + if not self.mimicRestores then return end + local keep = {} + -- iterate newest-first so the oldest snapshot (the true pre-Mimic + -- move id, from before any repeated Mimic-on-Mimic via Metronome) + -- is applied last and wins, instead of a stale intermediate id + for i = #self.mimicRestores, 1, -1 do + local r = self.mimicRestores[i] + if r.battler == battler then + r.entry.id, r.entry.mimic = r.id, nil + else + keep[#keep + 1] = r + end + end + self.mimicRestores = #keep > 0 and keep or nil +end + +-- BagWasSelected's old-man fork (core.asm:2193-2210): the list menu is +-- fed OldManItemList -- one POKé BALL x50 -- instead of the player's +-- bag. The list is as scripted as the battle menu (DisplayListMenuID's +-- old-man branch, home/list_menu.asm:65-80): no input is ever read -- +-- backing out is impossible -- the '▶' sits in front of POKé BALL for +-- 80 frames, then A is auto-pressed and .buttonAPressed's +-- PlaceUnfilledArrowMenuCursor leaves the hollow '▷' on the row for the +-- handful of frames UseBagItem takes to reach ItemUseBall's screen +-- restore (item_effects.asm:145) and the throw text. +function BattleState:openOldManBag() + local ListMenu = require("src.ui.ListMenu") + local game = self.game + self.phase = "messages" + self.afterQueue = "menu" + self:ui(function() + local list + list = ListMenu.new(game, "ITEMS", { + { value = "POKE_BALL", label = "POKé BALL", right = "x50" }, + }, { + script = function(l) + l.scriptTimer = (l.scriptTimer or 0) + 1 + if l.scriptTimer == 81 then + -- the auto A-press: the cursor goes hollow on the chosen row + l.hollowIndex = l.index + elseif l.scriptTimer > 88 then + -- ItemUseBall takes over: list down, OLD MAN throws + l:close() + self:oldManThrow() + end + end, + }) + return list + end) +end + +-- ItemUseBall for BATTLE_TYPE_OLD_MAN: the party/box-full checks are +-- skipped (item_effects.asm:114-118), every capture calculation is +-- skipped -- the old man branch jumps straight to .captured, $43 anim +-- data = 3 shakes and caught (:155-164 + :193-200) -- and +-- .oldManCaughtMon prints the caught text WITHOUT adding the mon to +-- the party or the dex (:568-570). The "used" line reads OLD MAN +-- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); +-- no ball is consumed (.done returns early, :576-578). +function BattleState:oldManThrow() + self.phase = "messages" + self.afterQueue = "finish" + self.result = "run" -- nothing is kept; wBattleResult only ends the demo + self:say("OLD MAN used\nPOKé BALL!") + self:act(function() + require("src.core.Sound").play(self.data, "Ball_Toss") + -- ItemUseBall's beat before the toss chain (like throwBall) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 20 }) + self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") + self:actNext(function() + require("src.core.Sound").play(self.data, "Caught_Mon") + end) + self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + end) +end + +-- --------------------------------------------------------------------- +-- turn resolution +-- --------------------------------------------------------------------- + +function BattleState:moveDef(moveInst) + return self.data.moves[moveInst.id] +end + +-- wAICount: item/switch uses per enemy Pokémon for this trainer class +function BattleState:aiUsesFor() + if self.kind ~= "trainer" or not self.trainer then return 0 end + local class = require("data.scripts.ai_classes")[self.trainer.id] + return class and class.uses or 0 +end + +-- Exp participants: every player mon that has been in against the +-- current enemy mon (wPartyGainExpFlags). +function BattleState:markParticipant() + self.participants = self.participants or {} + if self.player and self.player.mon then + self.participants[self.player.mon] = true + end +end + +function BattleState:enemyAction() + local locked = self:lockedAction(self.enemy) + if locked then return locked end + -- class AI may spend the turn on an item or a switch + local classAct = TrainerAI.classAction(self) + if classAct then return classAct end + return TrainerAI.chooseMove(self.enemy, self.rng, self) +end + +local function orderMove(action, data) + if action and action.id then return data.moves[action.id] end + return nil +end + +function BattleState:resolveTurn(playerAction) + local enemyAction = self:enemyAction() + local pFirst = TurnOrder.firstMover(self.player, orderMove(playerAction, self.data), + self.enemy, orderMove(enemyAction, self.data), + self.rng) + local order + if pFirst then + order = { { self.player, self.enemy, playerAction }, + { self.enemy, self.player, enemyAction } } + else + order = { { self.enemy, self.player, enemyAction }, + { self.player, self.enemy, playerAction } } + end + + self.phase = "messages" + self.afterQueue = "menu" + self.turnCount = (self.turnCount or 0) + 1 + + for _, entry in ipairs(order) do + self:act(function() + self:executeAction(entry[1], entry[2], entry[3]) + end) + end + self:act(function() self:endOfTurn() end) +end + +-- A switch action: replace the player's mon, enemy gets a free move. +function BattleState:resolveSwitch(newMon) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:restoreMimicked(self.player) -- the battle copy leaves with it + self.player = makeBattler(self.data, newMon, true, self.game.save) + self:markParticipant() + self.sendingOut = true + self:sayNext(self:sendOutText(self.player.name)) + self:animNext("POOF_ANIM", false) + self:actNext(function() + self.sendingOut = false + -- SendOutMon (core.asm:1757-1762): poof, then the grow-in + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + end) + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) +end + +function BattleState:endOfTurn() + -- sideToxic mirrors w*ToxicCounter: it advances only while the + -- battler's badly-poisoned flag (toxicCounter) is set, an item/AI + -- cure clears the flag but NOT the side counter, and a fresh Toxic + -- re-seeds it (effects.asm:137-139 zeroes the counter when setting + -- BADLY_POISONED). It is never copied back onto a battler: pokered + -- reads the counter only while the flag is set, and the only code + -- that sets the flag also zeroes the counter, so a stale value is + -- unobservable (a switch or cure downgrades Toxic to plain poison). + self.sideToxic = self.sideToxic or {} + for _, pair in ipairs({ { self.player, self.enemy, "player" }, + { self.enemy, self.player, "enemy" } }) do + local b, opp, side = pair[1], pair[2], pair[3] + if b.mon.hp > 0 then + local msgs = Status.residual(b, opp) + for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end + if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved + if b.toxicCounter then + self.sideToxic[side] = b.toxicCounter + end + if b.mon.hp <= 0 then + self:onFaint(b) + end + end + -- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that + -- hit 0 this turn releases its bit only now, at the end of the turn + if b.trappingTurns and b.trappingTurns <= 0 then + b.trappingTurns = nil + end + end +end + +-- --------------------------------------------------------------------- +-- battle animation layer +-- --------------------------------------------------------------------- +-- +-- An approximation of the original's subanimation bytecode engine +-- (docs/known-differences.md): the move's real sound (data/moves/sfx.asm +-- via each move's anim table), screen shake / flash for moves whose +-- animation data uses SE_SHAKE_SCREEN / screen-flash effects, target +-- blink on damage, and a faint slide with the cry. The Poké Ball toss +-- chain (toss/poof/hide/shake/show) rides the queue as anim rows. + +-- the OPTIONS animation toggle (sounds always play) +function BattleState:animationsOn() + local o = self.game.save.options + return not o or o.animations ~= false +end + +-- ------------------------------------------------------------------ +-- special-effect (SE_*) implementations. Palette effects are BGP +-- shade maps ({[i] = shade color index i displays as}); on the SGB the +-- colorizer colors the REMAPPED shade, so the zone palettes are +-- permuted through the active map (engine/battle/animations.asm +-- SetAnimationBGPalette / AnimationFlashScreen / ...ScreenLong). +-- ------------------------------------------------------------------ + +local BGP_IDENTITY = { [0] = 0, 1, 2, 3 } -- $e4 +local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1) +local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2) +local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette +local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette +local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB) + +-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle, +-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1 +-- (FlashScreenLongDelay) +local FLASH_LONG_MAPS = { + { [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, + { [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, + { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, + { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, +} + +-- the shade map in force this frame (a running flash wins over the +-- persistent palette) +function BattleState:activeBgp() + local fx = self.fx + if not fx then return nil end + local seq = fx.bgpSeq + if seq then + local st = seq.steps[seq.idx] + if st then return st.map end + end + return fx.bgp +end + +-- per-battler pic effect state (offsets/hides driven by the SE rows) +function BattleState:picFxFor(battler) + if not battler then return nil end + self.picFx = self.picFx or {} + local pf = self.picFx[battler] + if not pf then + pf = { ox = 0, oy = 0 } + self.picFx[battler] = pf + end + return pf +end + +-- transient pic effects reset when a new animation row starts (each +-- PlayAnimation redraws from a clean slate); `minimized` survives -- +-- the minimize sprite replaces the pic DATA, so redraws keep it until +-- the pic is reloaded (switch/Transform/ChangeMonPic) +function BattleState:resetPicFx() + if not self.picFx then return end + for _, pf in pairs(self.picFx) do + pf.kind, pf.t = nil, nil + pf.ox, pf.oy = 0, 0 + pf.hidden = nil + end +end + +-- the battler an SE row's routine acts on: "the mon" is the attacker's +-- side; the SE_*_ENEMY_* variants run through CallWithTurnFlipped +function BattleState:animFxBattler(flipped) + local isPlayer = self.animAttackerIsPlayer + if flipped then isPlayer = not isPlayer end + return isPlayer and self.player or self.enemy +end + +-- a row's sound byte is a move id: GetMoveSound plays its +-- MoveSoundTable sfx with the pitch/tempo modifier bytes; for the +-- GROWL/ROAR animations (IsCryMove) it plays the attacker's cry, with +-- the move's own pitch/tempo bytes (from its own row, soundMove == +-- self.animName for these) layered on as the extra shift +function BattleState:playAnimSound(soundMove) + local Sound = require("src.core.Sound") + local mdef = self.data.moves[soundMove] + if self.animName == "GROWL" or self.animName == "ROAR" then + local attacker = self:animFxBattler(false) + if attacker then + Sound.playMoveCry(self.data, attacker.mon.species, + mdef and mdef.anim and mdef.anim.tempo) + end + return + end + if mdef and mdef.anim then + if Sound.playMove then + Sound.playMove(self.data, mdef.anim) + else + Sound.play(self.data, mdef.anim.sound) + end + end +end + +local function startPicKind(pf, kind) + if not pf then return end + pf.kind, pf.t = kind, 0 + pf.hidden = nil +end + +-- Route one AnimPlayer event into the fx layer. Frame counts and +-- amplitudes are the routines' own (engine/battle/animations.asm; +-- shakes: engine/gfx/screen_effects.asm). +function BattleState:applyAnimEffect(ev) + self.fx = self.fx or {} + local fx = self.fx + if ev.sound then + self:playAnimSound(ev.sound) + end + local e = ev.effect + if not e then return end + + if e == "SFX_TINK" then + -- each ball shake opens with a tink (DoBallShakeSpecialEffects) + require("src.core.Sound").play(self.data, "Tink") + + -- ---------------------------------------------- palette effects + elseif e == "SE_DARK_SCREEN_PALETTE" then + fx.bgp = BGP_DARK + elseif e == "SE_LIGHT_SCREEN_PALETTE" then + fx.bgp = BGP_LIGHT + elseif e == "SE_DARKEN_MON_PALETTE" then + fx.bgp = BGP_DARKEN + elseif e == "SE_RESET_SCREEN_PALETTE" then + fx.bgp = nil + elseif e == "SE_DARK_SCREEN_FLASH" then + -- AnimationFlashScreen: 2 frames inverted, 2 frames white, restore + fx.bgpSeq = { steps = { { map = BGP_INVERT, frames = 2 }, + { map = BGP_WHITE, frames = 2 } }, + idx = 1, left = 2 } + elseif e == "SE_FLASH_SCREEN_LONG" then + local steps = {} + for cycle = 1, 3 do + for _, m in ipairs(FLASH_LONG_MAPS) do + steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 } + end + end + fx.bgpSeq = { steps = steps, idx = 1, left = steps[1].frames } + + -- ---------------------------------------------- screen shakes + elseif e == "SE_SHAKE_SCREEN" then + -- PredefShakeScreenHorizontally b=8: the window jumps right by b + -- for 5 frames then home for 4, b counting down 8..1 + local prog = {} + for b = 8, 1, -1 do + prog[#prog + 1] = { dx = b, frames = 5 } + prog[#prog + 1] = { dx = 0, frames = 4 } + end + fx.shakeProg = prog + elseif e == "SE_ROCK_SLIDE_SHAKE" then + -- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble + 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 = {} + for _ = 1, 8 do + prog[#prog + 1] = { dx = 2, frames = 2 } + prog[#prog + 1] = { dx = -2, frames = 2 } + end + fx.hudShakeProg = prog + elseif e == "SE_WAVY_SCREEN" then + -- AnimationWavyScreen: 255 frames of per-scanline SCX offsets + -- walking WavyScreenLineOffsets + fx.wavy = { left = 255, phase = 0 } + + -- ---------------------------------------------- mon pic effects + elseif e == "SE_SLIDE_MON_OFF" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "slideOff") + elseif e == "SE_SLIDE_ENEMY_MON_OFF" then + startPicKind(self:picFxFor(self:animFxBattler(true)), "slideOff") + elseif e == "SE_SLIDE_MON_HALF_OFF" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "slideHalf") + elseif e == "SE_SLIDE_MON_UP" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "slideUp") + 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") + elseif e == "SE_SHAKE_BACK_AND_FORTH" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "shakeBF") + elseif e == "SE_BOUNCE_UP_AND_DOWN" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "bounce") + elseif e == "SE_SQUISH_MON_PIC" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "squish") + elseif e == "SE_BLINK_MON" then + startPicKind(self:picFxFor(self:animFxBattler(false)), "blink") + elseif e == "SE_BLINK_ENEMY_MON" then + startPicKind(self:picFxFor(self:animFxBattler(true)), "blink") + elseif e == "SE_MOVE_MON_HORIZONTALLY" then + -- redraw one tile inward: player pic at hlcoord 2,5 (from 1,5), + -- enemy pic at 11,0 (from 12,0) + local b = self:animFxBattler(false) + local pf = self:picFxFor(b) + if pf then + pf.kind, pf.hidden = nil, nil + pf.ox = b.isPlayer and 8 or -8 + pf.oy = 0 + end + elseif e == "SE_RESET_MON_POSITION" then + local pf = self:picFxFor(self:animFxBattler(false)) + if pf then + pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0 + 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 + 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 + 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 + elseif e == "SE_HIDE_ENEMY_MON_PIC" then + local pf = self:picFxFor(self:animFxBattler(true)) + if pf then pf.kind, pf.hidden = nil, true end + elseif e == "SE_MINIMIZE_MON" then + -- the pic data is replaced by the tiny MinimizedMonSprite blob + local pf = self:picFxFor(self:animFxBattler(false)) + if pf then + pf.kind, pf.hidden = nil, nil + pf.minimized = true + end + 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 + elseif e == "SE_TRANSFORM_MON" then + -- AnimationTransformMon redraws the user as the opposing species + -- (MoveEffects.TRANSFORM_EFFECT swaps the rest when it applies) + local user = self:animFxBattler(false) + local target = self:animFxBattler(true) + if user and target and self.speciesSprite then + user.sprite = self:speciesSprite(target.mon.species, user.isPlayer) + or user.sprite + local pf = self:picFxFor(user) + if pf then pf.minimized = nil end + end + end + -- SE_SUBSTITUTE_MON needs no visual here: the doll is drawn while + -- battler.substituteHP is set (MoveEffects raises it with the move) +end + +-- The target's post-animation hit feedback (PlayApplyingAttackAnimation, +-- engine/battle/animations.asm:475): the player's damaging moves blink +-- the ENEMY pic; the enemy's damaging moves shake the screen vertically +-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window +-- drops by b for 3 frames then home for 3, b counting down) -- the +-- player's pic never blinks. Damage sound with either. A hold keeps +-- the queue still until the effect finishes. +function BattleState:applyHitFx(hit) + if hit.blink then + self.fx = self.fx or {} + if hit.blink.isPlayer then + local prog = {} + for b = 8, 1, -1 do + prog[#prog + 1] = { dy = b, frames = 3 } + prog[#prog + 1] = { dy = 0, frames = 3 } + end + self.fx.shakeProg = prog + self.waitFrames = 48 -- the predef blocks until the shake settles + else + self.fx.blink = { target = hit.blink, frames = 20 } + self.waitFrames = 20 + end + end + if hit.sfx then + require("src.core.Sound").play(self.data, hit.sfx) + end +end + +-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the +-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3 +-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size. +-- Queues a hold so the text stays up while it grows. Runs inside a +-- queued fn (updateQueue resets nextInsert before each one). +function BattleState:startGrowIn(battler) + self.growIn = { battler = battler, frame = 0 } + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 12 }) +end + +-- Should the low-health alarm sound this frame? pokered keys it off +-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets +-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is +-- red (< 10 of 48 pixels -- the same threshold HudTiles.drawHPBar +-- tints with) and clears it when the bar isn't red or the mon fainted +-- (RemoveFaintedPlayerMon). Winning disables it for the rest of the +-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by +-- playVictoryMusic) and every other outcome tears it down in +-- end_of_battle.asm -- self.result covers those. The damage drain +-- gates the start (the HUD redraw runs after UpdateHPBar finishes), +-- but healing out of the red stops it at once (item_effects.asm clears +-- the alarm before the bar animates). No alarm before the player HUD +-- first draws (send-out), nor in the safari/old-man battles, which +-- have no player mon HUD. +function BattleState:lowHealthAlarmActive() + local p = self.player + if not p or self.safari or self.demo or self.result + or self.lowHealthAlarmDisabled then return false end + if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end + local hp = p.mon.hp + if hp <= 0 or p.fainted then return false end + if p.shownHP and p.shownHP > hp then return false end -- drain running + local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp))) + return px < 10 +end + +-- advance a {dx/dy, frames} step program; returns the current step +local function stepProgram(prog) + local head = prog[1] + while head and head.frames <= 0 do + table.remove(prog, 1) + head = prog[1] + end + if head then head.frames = head.frames - 1 end + return head +end + +function BattleState:updateFx() + if self.introSlide and self.introSlide > 0 then + self.introSlide = self.introSlide - 1 + end + local fx = self.fx + if fx then + if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end + if fx.flash and fx.flash > 0 then fx.flash = fx.flash - 1 end + if fx.blink and fx.blink.frames > 0 then + fx.blink.frames = fx.blink.frames - 1 + end + if fx.faint and fx.faint.frames > 0 then + fx.faint.frames = fx.faint.frames - 1 + end + -- SE-driven screen offsets (window/SCX shakes) + fx.shakeX, fx.shakeY = 0, 0 + if fx.shakeProg then + local st = stepProgram(fx.shakeProg) + if st then + fx.shakeX, fx.shakeY = st.dx or 0, st.dy or 0 + else + fx.shakeProg = nil + end + end + fx.hudShakeX = 0 + if fx.hudShakeProg then + local st = stepProgram(fx.hudShakeProg) + if st then + fx.hudShakeX = st.dx or 0 + else + fx.hudShakeProg = nil + end + end + -- BGP flash sequences + local seq = fx.bgpSeq + if seq then + seq.left = seq.left - 1 + if seq.left <= 0 then + seq.idx = seq.idx + 1 + local st = seq.steps[seq.idx] + if st then + seq.left = st.frames + else + fx.bgpSeq = nil -- restore: activeBgp falls back to fx.bgp + end + end + end + if fx.wavy then + fx.wavy.left = fx.wavy.left - 1 + fx.wavy.phase = fx.wavy.phase + 1 + if fx.wavy.left <= 0 then fx.wavy = nil end + end + end + -- SE-driven pic effects: advance the per-battler programs and apply + -- their end states (timings in the SE_* handlers' comments) + if self.picFx then + for b, pf in pairs(self.picFx) do + if pf.kind then + pf.t = (pf.t or 0) + 1 + local k, t = pf.kind, pf.t + if k == "slideOff" and t >= 24 then + pf.kind, pf.hidden = nil, true + elseif k == "slideHalf" and t >= 19 then + pf.kind = nil + pf.ox = b.isPlayer and -32 or 32 -- the pic stays half off + elseif k == "slideUp" and t >= 14 then + pf.kind = nil -- a full cyclic wrap lands back on the pic + elseif k == "slideDown" and t >= 21 then + pf.kind, pf.hidden = nil, true + elseif k == "slideDownHide" and t >= 19 then + pf.kind, pf.hidden = nil, true + elseif k == "shakeBF" and t >= 96 then + pf.kind, pf.hidden = nil, true -- the loop ends on a cleared pic + elseif k == "bounce" and t >= 105 then + 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 + pf.kind = nil -- ends shown + end + end + end + end + -- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then + -- the pic draws at full size again + if self.growIn then + self.growIn.frame = self.growIn.frame + 1 + if self.growIn.frame >= 12 then self.growIn = nil end + end + -- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren + -- loops while the player's bar is red; see lowHealthAlarmActive + local Sound = require("src.core.Sound") + if self:lowHealthAlarmActive() then + Sound.startLoop(self.data, "Low_Health_Alarm") + else + Sound.stopLoop("Low_Health_Alarm") + end +end + +-- --------------------------------------------------------------------- +-- move execution pipeline +-- --------------------------------------------------------------------- + +function BattleState:executeAction(user, target, action) + if user.mon.hp <= 0 or target.mon.hp <= 0 then return end + if not action then return end + + -- ghost battles: the ghost never attacks; its whole turn is the + -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) + if self.ghost and not user.isPlayer then + self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") + return + end + + -- refresh the held-in-place mirror before the status checks (see + -- lockedAction): the victim is held exactly while the opponent's + -- trapping bit is set -- including a counter sitting at 0 until the + -- end-of-turn CheckNumAttacksLeft clear + user.boundTurns = target.trappingTurns + and math.max(1, target.trappingTurns) or nil + + -- trainer class AI actions (engine/battle/trainer_ai.asm) + if action.special == "aiItem" then + self.aiUses = (self.aiUses or 1) - 1 + for _, m in ipairs(TrainerAI.useItem(self, action.item)) do + self:sayNext(prefixEnemy(m, self.enemy)) + end + self:drainNext() + require("src.core.Sound").play(self.data, "Heal_Ailment") + return + end + if action.special == "aiSwitch" then + self.aiUses = (self.aiUses or 1) - 1 + local oldName = self.enemy.name + self.enemyIndex = action.index + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.aiUses = self:aiUsesFor() + markSeen(self.game, self.enemy.mon.species) + -- _AIBattleWithdrawText: "X with-/drew Y!" + self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) + self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + return + end + + -- special locked actions. All of them still run the status gauntlet: + -- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep -> + -- freeze -> held-in-place -> flinch -> recharge -> disable tick -> + -- confusion -> paralysis BEFORE the bide/thrash/trapping handling. + if action.special == "recharge" then + -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- + -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn + if self:preRechargeChecks(user, target) then return end + user.mustRecharge = nil + self:sayNext(("%s\nmust recharge!"):format(displayName(user))) + return + end + if action.special == "bound" then + if not target.trappingTurns then + -- the trap ended earlier this turn: the CANNOT_MOVE selection is + -- simply lost (ExecutePlayerMove returns immediately on $ff) + return + end + -- sleep/freeze take precedence over the held-in-place message + if self:statusInterrupt(user, target) then return end + return + end + if action.special == "trapping" then + if self:statusInterrupt(user, target) then return end + self:continueTrapping(user, target) + return + end + if action.special == "bide" then + if self:statusInterrupt(user, target) then return end + self:continueBide(user, target) + return + end + + if self:statusInterrupt(user, target) then return end + self:performMove(user, target, action, false) +end + +-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm: +-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing +-- the turn WITHOUT consuming the recharge flag. The disable/confusion/ +-- paralysis ticks come after the recharge consume in the asm, so they +-- must not run on a recharge turn. Mirrors Status.beforeMove's early +-- checks (kept there for normal moves). +function BattleState:preRechargeChecks(user, target) + if user.skipMove then -- Haze forfeit (selected move = CANNOT_MOVE) + user.skipMove = nil + return true + end + local mon = user.mon + if mon.status == "SLP" then + user.sleepTurns = (user.sleepTurns or 1) - 1 + if user.sleepTurns <= 0 then + mon.status = nil + self:sayNext(displayName(user) .. "\nwoke up!") + else + self:sayNext(displayName(user) .. "\nis fast asleep!") + end + return true + end + if mon.status == "FRZ" then + self:sayNext(displayName(user) .. "\nis frozen solid!") + return true + end + if target.trappingTurns then + self:sayNext(displayName(user) .. "\ncan't move!") + return true + end + if user.flinched then + -- reachable: the turn-start flinch reset is skipped while the + -- player recharges, so the flinch eats the recharge turn and the + -- flag survives (the Hyper Beam flinch glitch) + user.flinched = false + self:sayNext(displayName(user) .. "\nflinched!") + return true + end + return false +end + +-- Runs Status.beforeMove plus the shared interruption bookkeeping; +-- returns true when the user's action is interrupted. +function BattleState:statusInterrupt(user, target) + local canMove, msgs, selfHit = Status.beforeMove(user, self.rng) + for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end + if selfHit then + -- confusion self-hit (core.asm:3428-3434): clears everything in + -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a + -- 40-power typeless hit against the mon's own defense -- with the + -- OPPONENT's Reflect still applying (the screen check keeps + -- reading the opponent's battle status) + local dmg = Damage.compute(self.ruleset, user, user, + { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, + { rng = self.rng, forceCrit = false, typeless = true, + screens = target }) + self:sayNext("It hurt itself in\nits confusion!") + self:clearVolatiles(user, true) + self:applyDamage(user, dmg) + if user.mon.hp <= 0 then self:onFaint(user) end + return true + end + if not canMove then + -- full paralysis (core.asm:3459-3464) clears bide/thrash/charge/ + -- trapping; sleep, freeze, flinch and held-in-place leave every + -- volatile in place (a sleeping wrapper keeps its victim held) + if user.mon.status == "PAR" and msgs[#msgs] + and msgs[#msgs]:find("fully paralyzed", 1, true) then + self:clearVolatiles(user, false) + end + return true + end + return false +end + +-- The status1 volatile clears shared by full paralysis and the +-- confusion self-hit. selfHit additionally clears INVULNERABLE and +-- FLINCHED (status1 &= CONFUSED); full paralysis does NOT touch +-- INVULNERABLE -- the famous Fly/Dig invulnerability glitch. +function BattleState:clearVolatiles(user, selfHit) + user.bideTurns, user.bideDamage = nil, nil + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + user.charging, user.chargeReady = nil, nil + user.trappingTurns = nil -- the opponent is freed via the live mirror + if selfHit then + user.invulnerable = nil + user.flinched = false + end +end + +-- performMove runs a move (possibly via Metronome/Mirror Move recursion). +function BattleState:performMove(user, target, moveInst, isCalled) + local move = self:moveDef(moveInst) + if not move then + Logger.warn("unknown move instance %s", tostring(moveInst.id)) + return + end + + -- charge release? + local releasing = user.charging == moveInst and user.chargeReady + if releasing then + user.charging, user.chargeReady, user.invulnerable = nil, nil, nil + end + + -- PP: not for continuations, struggle, or called moves + local isContinuation = releasing + or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) + or moveInst == user.rageMove + if not isContinuation and not moveInst.struggle and not isCalled then + moveInst.pp = math.max(0, moveInst.pp - 1) + end + + local effect = move.effect + + self.moveAnimRow = nil + if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then + self:sayNext(("%s\nused %s!"):format(displayName(user), move.name)) + -- the move's animation plays right after the announcement; the + -- damage path attaches the target's hit blink to this row so the + -- blink follows the animation (pokered's order). Mimic is the + -- exception: PlayCurrentMoveAnimation runs only after a successful + -- copy (effects.asm:1268), never on a miss -- applyMimic queues it + if effect ~= "MIMIC_EFFECT" then + self.nextInsert = (self.nextInsert or 0) + 1 + self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer } + table.insert(self.queue, self.nextInsert, self.moveAnimRow) + end + end + + -- Metronome / Mirror Move + if effect == "METRONOME_EFFECT" then + local order = self.data.constants.moveOrder + local pick + repeat + pick = order[self.rng(1, #order)] + until pick ~= "METRONOME" and pick ~= "STRUGGLE" and self.data.moves[pick] + self:performMove(user, target, { id = pick, pp = 1 }, true) + return + end + if effect == "MIRROR_MOVE_EFFECT" then + local last = target.lastMove + if not last then + self:sayNext("The MIRROR MOVE\nfailed!") + return + end + self:performMove(user, target, { id = last, pp = 1 }, true) + return + end + + user.lastMove = move.id + + -- charge moves: first turn just charges; Fly AND Dig go + -- semi-invulnerable (ChargeEffect sets INVULNERABLE for both) + if (effect == "CHARGE_EFFECT" or effect == "FLY_EFFECT") and not releasing then + user.charging = moveInst + user.chargeReady = true + local chargeText = ({ + FLY = "%s\nflew up high!", + DIG = "%s\ndug a hole!", + RAZOR_WIND = "%s\nmade a whirlwind!", + SOLARBEAM = "%s\ntook in sunlight!", + SKULL_BASH = "%s\nlowered its head!", + SKY_ATTACK = "%s\nis glowing!", + })[move.id] or "%s\nis charging up!" + if effect == "FLY_EFFECT" or move.id == "DIG" then + user.invulnerable = true + end + self:sayNext(chargeText:format(displayName(user))) + return + end + + if effect == "SWITCH_AND_TELEPORT_EFFECT" then + -- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle + -- it auto-succeeds when the user's level >= the opponent's; + -- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the + -- roll is below opponentLevel/4. Teleport's failure text is "But + -- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer + -- battles Teleport fails and Roar/Whirlwind are "unaffected". + if self.kind == "wild" then + local uLvl, tLvl = user.mon.level, target.mon.level + local ok = uLvl >= tLvl + if not ok then + ok = self.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4) + end + if ok then + if move.id == "ROAR" then + self:sayNext(("%s\nran away scared!"):format(displayName(target))) + elseif move.id == "WHIRLWIND" then + self:sayNext(("%s\nwas blown away!"):format(displayName(target))) + else + self:sayNext(("%s\nran from battle!"):format(displayName(user))) + end + self.result = "run" + self.afterQueue = "finish" + elseif move.id == "TELEPORT" then + self:sayNext("But, it failed!") + else + self:sayNext(("It didn't affect\n%s!"):format(displayName(target))) + end + elseif move.id == "TELEPORT" then + self:sayNext("But, it failed!") + else + self:sayNext(("%s\nis unaffected!"):format(displayName(target))) + end + return + end + + if effect == "BIDE_EFFECT" then + user.bideTurns = self.rng(2, 3) + user.bideDamage = 0 + self:sayNext(("%s\nis storing energy!"):format(displayName(user))) + return + end + + -- Mimic runs its own mid-move flow: hit test, then the copy menu + -- (player) or a random roll (enemy / link), all on the queue + if effect == "MIMIC_EFFECT" then + self:resolveMimic(user, target, move, moveInst) + return + end + + -- pure status moves + local primary = MoveEffects.primary[effect] + if move.power == 0 and primary then + -- accuracy-checked status effects run MoveHitTest, which has no + -- 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 ACC_CHECKED_STATUS[effect] + and (target.invulnerable + or not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng)) then + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + return + end + for _, m in ipairs(primary(self, user, target, move, moveInst)) do + self:sayNext(m) + end + self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar + return + end + if move.power == 0 and not MoveEffects.special[effect] then + MoveEffects.warnUnknown(effect) + self:sayNext("But, it failed!") + return + end + + -- damaging move --------------------------------------------------------- + + -- Swift ignores semi-invulnerability (MoveHitTest returns hit for + -- SWIFT_EFFECT before the INVULNERABLE check) + if target.invulnerable and effect ~= "SWIFT_EFFECT" then + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + return + end + + if effect == "OHKO_EFFECT" then + -- fails against faster opponents (Gen 1 rule) and immune types + if TypeChart.effectiveness(move.type, target.curTypes) == 0 then + self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) + return + end + if TurnOrder.effectiveSpeed(user) < TurnOrder.effectiveSpeed(target) then + self:sayNext("But, it failed!") + return + end + end + + -- Dream Eater only works on sleeping targets (checked before damage) + if effect == "DREAM_EATER_EFFECT" and target.mon.status ~= "SLP" then + self:sayNext("But, it failed!") + return + end + + local hits = 1 + if effect == "TWO_TO_FIVE_ATTACKS_EFFECT" then + local r = self.rng(0, 7) + hits = ({ 2, 2, 2, 3, 3, 3, 4, 5 })[r + 1] + elseif effect == "ATTACK_TWICE_EFFECT" or effect == "TWINEEDLE_EFFECT" then + hits = 2 + end + + -- TrappingEffect runs BEFORE the hit test and clears the target's + -- Hyper Beam recharge, even if the trapping move then misses + -- (effects.asm:1091-1092 ClearHyperBeam) + if effect == "TRAPPING_EFFECT" and not user.trappingTurns then + target.mustRecharge = nil + end + + -- accuracy (Swift never misses) + if effect ~= "SWIFT_EFFECT" then + if not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng) then + if effect == "JUMP_KICK_EFFECT" then + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + self:sayNext(("%s\nkept going and\ncrashed!"):format(displayName(user))) + self:applyDamage(user, 1) + if user.mon.hp <= 0 then self:onFaint(user) end + elseif effect == "EXPLODE_EFFECT" then + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + self:selfDestruct(user) + else + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + end + user.trappingTurns = nil + return + end + end + + -- damage per hit + local dmg, info + if move.id == "COUNTER" then + -- HandleCounterMove: 2x the last damage dealt in battle, only if + -- the opponent's last move was Normal/Fighting with >0 power (and + -- not Counter itself); wDamage is shared, so any last damage counts + local lastId = target.lastMove + local lm = lastId and lastId ~= "COUNTER" and self.data.moves[lastId] + local counterable = lm and (lm.power or 0) > 0 + and (lm.type == "NORMAL" or lm.type == "FIGHTING") + if not counterable or (self.lastDamage or 0) == 0 then + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + return + end + dmg = math.min(65535, self.lastDamage * 2) + info = { crit = false, typeMult = 10 } + elseif effect == "SPECIAL_DAMAGE_EFFECT" or effect == "SUPER_FANG_EFFECT" then + -- fixed damage still respects type immunity (AdjustDamageForMoveType + -- flags the miss before the special-damage override) + if TypeChart.effectiveness(move.type, target.curTypes) == 0 then + self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) + return + end + if effect == "SUPER_FANG_EFFECT" then + dmg = math.max(1, math.floor(target.mon.hp / 2)) + else + dmg = self:specialDamage(user, target, move) + if not dmg then + self:sayNext("But, it failed!") + return + end + end + info = { crit = false, typeMult = 10 } + elseif effect == "OHKO_EFFECT" then + dmg = 65535 + info = { crit = false, typeMult = 10 } + else + dmg, info = Damage.compute(self.ruleset, user, target, move, + { rng = self.rng, explode = effect == "EXPLODE_EFFECT" }) + end + + if info.typeMult == 0 then + self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) + if effect == "EXPLODE_EFFECT" then self:selfDestruct(user) end + return + end + if info.missed then + -- 0.25x floored the damage to zero: the original registers a miss + self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + if effect == "EXPLODE_EFFECT" then self:selfDestruct(user) end + return + end + self.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) + + -- the hit blink + damage sound ride the queue behind the animation: + -- on the move's anim row when one was announced, else on a bare hit + -- row (thrash/rage continuations), placed BEFORE the drain rows the + -- hits loop inserts so the blink precedes the bar drain + local hitRow = self.moveAnimRow + if not hitRow then + self.nextInsert = (self.nextInsert or 0) + 1 + hitRow = { hitRow = true } + table.insert(self.queue, self.nextInsert, hitRow) + end + + local totalDealt = 0 + local hitCount, brokeSub = 0, false + for h = 1, hits do + if target.mon.hp <= 0 then break end + local hadSub = target.substituteHP ~= nil + totalDealt = totalDealt + self:applyDamage(target, dmg) + hitCount = h + if hadSub and not target.substituteHP then + -- AttackSubstitute: breaking the substitute ends a multi-hit move + brokeSub = true + break + end + end + hits = hitCount > 0 and hitCount or hits + if totalDealt > 0 then + -- the original's per-hit sound: normal / super / not-very-effective + local hitSfx = info.typeMult > 10 and "Super_Effective" + or info.typeMult < 10 and "Not_Very_Effective" or "Damage" + hitRow.hit = { sfx = hitSfx, + blink = self:animationsOn() and target or nil } + end + -- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right + -- after the damage lands, BEFORE DisplayEffectiveness (core.asm + -- .moveDidNotMiss); the multi-hit count follows the last hit + if info.crit then self:sayNext("Critical hit!") end + if effect == "OHKO_EFFECT" then + self:sayNext("One-hit KO!") + end + if info.typeMult > 10 then + self:sayNext("It's super\neffective!") + elseif info.typeMult < 10 then + self:sayNext("It's not very\neffective...") + end + if hits > 1 then + -- player: _MultiHitText; enemy: _HitXTimesText (always plural) + if user.isPlayer then + self:sayNext(("Hit the enemy\n%d times!"):format(hits)) + else + self:sayNext(("Hit %d times!"):format(hits)) + end + end + + -- post-damage effect bookkeeping + if effect == "RECOIL_EFFECT" or moveInst.struggle then + -- recoil.asm reads the RAW computed wDamage (not the HP actually + -- removed): overkill and substitute hits recoil at full strength + local recoil = math.max(1, math.floor(dmg / (moveInst.struggle and 2 or 4))) + self:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) + self:applyDamage(user, recoil) + elseif effect == "DRAIN_HP_EFFECT" or effect == "DREAM_EATER_EFFECT" then + -- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and + -- heals that amount, so Counter would see the halved value + local heal = math.max(1, math.floor(dmg / 2)) + self.lastDamage = heal + user.mon.hp = math.min(user.mon.stats.hp, user.mon.hp + heal) + self:drainNext() + if effect == "DREAM_EATER_EFFECT" then + self:sayNext(("%s's\ndream was eaten!"):format(displayName(target))) + else + self:sayNext(("Sucked health from\n%s!"):format(displayName(target))) + end + elseif effect == "EXPLODE_EFFECT" then + self:selfDestruct(user) + elseif effect == "HYPER_BEAM_EFFECT" then + -- no recharge when the target faints OR its substitute breaks + if target.mon.hp > 0 and not brokeSub then + user.mustRecharge = true + end + elseif effect == "PAY_DAY_EFFECT" then + self.payDay = (self.payDay or 0) + 2 * user.mon.level + self:sayNext("Coins scattered\neverywhere!") + elseif effect == "TRAPPING_EFFECT" then + if not user.trappingTurns then + -- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft + -- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION + -- attacks follow this first hit, 2-5 attacks total. The victim + -- is held while the counter runs (live mirror in lockedAction). + local r = self.rng(0, 7) + user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1] + user.trapDamage = dmg + -- remember the move so its animation can replay on each locked + -- continuation (core.asm:3554-3566 -> GetPlayerAnimationType) + user.trapMove = move.id + end + elseif effect == "THRASH_PETAL_DANCE_EFFECT" then + if not user.thrashTurns then + user.thrashTurns = self.rng(2, 3) -- 3-4 attacks total, then confusion + user.thrashMove = moveInst + user.thrashAnnounced = true + else + user.thrashTurns = user.thrashTurns - 1 + if user.thrashTurns <= 0 then + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + if not user.confusedTurns then + user.confusedTurns = self.rng(2, 5) + self:sayNext(("%s\nbecame confused!"):format(displayName(user))) + end + end + end + elseif effect == "RAGE_EFFECT" then + user.rageMove = moveInst + end + + -- secondary side effects (blocked by fainting) + local secondary = MoveEffects.secondary[effect] + if secondary and target.mon.hp > 0 and totalDealt > 0 then + for _, m in ipairs(secondary(self, user, target, move)) do + self:sayNext(m) + end + end + if not MoveEffects.special[effect] and not MoveEffects.secondary[effect] + and not MoveEffects.primary[effect] and effect ~= "NO_ADDITIONAL_EFFECT" then + MoveEffects.warnUnknown(effect) + end + + if target.mon.hp <= 0 then + self:onFaint(target) + end + if user.mon.hp <= 0 then + self:onFaint(user) + end +end + +function BattleState:continueTrapping(user, target) + self:sayNext(("%s's\nattack continues!"):format(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). + -- Mirror performMove's anim row (BattleState.lua ~1307), gated on the + -- OPTIONS animation toggle. + if user.trapMove and self:animationsOn() then + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { anim = user.trapMove, attackerIsPlayer = user.isPlayer }) + end + -- the counter can sit at 0 until the END of the turn: the trapping + -- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467) + -- after BOTH battlers acted, so a slower victim is still held + -- through the attacker's final hit (endOfTurn nils it) + user.trappingTurns = user.trappingTurns - 1 + self:applyDamage(target, user.trapDamage or 1) + if target.mon.hp <= 0 then self:onFaint(target) end +end + +function BattleState:continueBide(user, target) + user.bideTurns = user.bideTurns - 1 + if user.bideTurns > 0 then + self:sayNext(("%s\nis storing energy!"):format(displayName(user))) + return + end + self:sayNext(("%s\nunleashed energy!"):format(displayName(user))) + local dmg = (user.bideDamage or 0) * 2 + user.bideTurns, user.bideDamage = nil, nil + if dmg <= 0 then + self:sayNext("But, it failed!") + return + end + self:applyDamage(target, dmg) + if target.mon.hp <= 0 then self:onFaint(target) end +end + +function BattleState:selfDestruct(user) + user.mon.hp = 0 + self:onFaint(user) +end + +-- Applies damage honoring Substitute, Bide storage and Rage; returns the +-- amount that counts as dealt (for recoil/drain). +function BattleState:applyDamage(target, dmg) + if target.substituteHP then + target.substituteHP = target.substituteHP - dmg + if target.substituteHP <= 0 then + target.substituteHP = nil + self:sayNext(("%s's\nSUBSTITUTE broke!"):format(displayName(target))) + else + self:sayNext(("The SUBSTITUTE\ntook damage for\n%s!"):format(displayName(target))) + end + return dmg + end + local dealt = math.min(dmg, target.mon.hp) + target.mon.hp = target.mon.hp - dealt + if dealt > 0 then self:drainNext() end -- animate the bar down + if target.bideTurns then + target.bideDamage = (target.bideDamage or 0) + dealt + end + if target.rageMove and dealt > 0 then + target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) + self:sayNext(("%s's\nRAGE is building!"):format(displayName(target))) + end + return dealt +end + +-- fixed-damage moves (engine/battle/core.asm SpecialDamage) +function BattleState:specialDamage(user, target, move) + local id = move.id + if id == "SONICBOOM" then return 20 end + if id == "DRAGON_RAGE" then return 40 end + if id == "SEISMIC_TOSS" or id == "NIGHT_SHADE" then return user.mon.level end + if id == "PSYWAVE" then + local max = math.max(1, math.floor(user.mon.level * 3 / 2) - 1) + return self.rng(1, max) + end + return nil +end + +-- --------------------------------------------------------------------- +-- fainting / exp / party +-- --------------------------------------------------------------------- + +function BattleState:onFaint(battler) + if battler.faintQueued then return end + battler.faintQueued = true + -- the faint slide + cry ride the queue (after the move animation and + -- the HP-bar drain, pokered's order); the slide finishes before the + -- faint text via a queued hold + self:actNext(function() + battler.fainted = true + local Sound = require("src.core.Sound") + Sound.playCry(self.data, battler.mon.species) + Sound.play(self.data, "Faint_Fall") + self.fx = self.fx or {} + self.fx.faint = { battler = battler, frames = 30 } + end) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 30 }) + if not battler.isPlayer and self.kind == "wild" then + -- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild + -- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON + -- as the slide lands, BEFORE EnemyMonFaintedText and the exp text; + -- trainer battles keep the battle theme until TrainerBattleVictory. + -- (Starting it even when the player mon dropped too matches the + -- acknowledged core.asm:797-798 bug.) + self:actNext(function() self:playVictoryMusic() end) + end + -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText + self:sayNext(("%s\nfainted!"):format(displayName(battler))) + if battler.isPlayer then + self:act(function() self:playerMonFainted() end) + else + self:act(function() self:enemyMonFainted() end) + end +end + +function BattleState:enemyMonFainted() + -- exp is split among the mons that fought this enemy + -- (engine/battle/experience.asm); traded mons earn x1.5; each + -- participant gets the full stat exp + -- the divisor counts EVERY participant, fainted ones included + -- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the + -- living ones are actually paid + local participants, alive = 0, {} + for _, mon in ipairs(self.game.save.party) do + if self.participants and self.participants[mon] then + participants = participants + 1 + if mon.hp > 0 then table.insert(alive, mon) end + end + end + if participants == 0 and self.player.mon.hp > 0 then + participants, alive = 1, { self.player.mon } + end + local function applyShare(mon, split, announce) + local levels, gained = Experience.apply(self.data, mon, self.enemy.def, + self.enemy.mon.level, self.kind == "trainer", + split, mon.traded) + local name = mon.nickname or self.data.pokemon[mon.species].name + if announce then + -- GainedText (experience.asm:342-354): "X gained" plus one of + -- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL + -- pass beats the traded boost (wBoostExpByExpAll checks first), + -- and _ExpPointsText prints wExpAmountGained -- the raw share, + -- captured before the max-level cap (experience.asm:92-100) + local tail = "%d EXP. Points!" + if announce == "expAll" then + tail = "with EXP.ALL,\n" .. tail + elseif mon.traded then + tail = "a boosted\n" .. tail + end + self:sayNext(("%s gained\n" .. tail):format(name, gained)) + end + -- per level: GrewLevelText -> the stats window (PrintStatsBox) -> + -- the move-learn checks (experience.asm:245-256) + local game = self.game + for _, lv in ipairs(levels) do + self:sayNext(("%s grew\nto level %d!"):format(name, lv)) + self:uiNext(function() + require("src.core.Sound").play(game.data, "Level_Up") + return StatBox.new(game, mon) + end) + for _, moveId in ipairs(Experience.movesLearnedAt( + self.data.pokemon[mon.species], lv)) do + self:learnMove(mon, moveId) + end + end + end + -- with EXP.ALL, participants split half the exp and the other half + -- is divided among the whole party (engine/battle/experience.asm) + local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 + for _, mon in ipairs(alive) do + applyShare(mon, participants * (expAll and 2 or 1), true) + end + if expAll then + -- the second GainExperience pass sets the gain flags for the WHOLE + -- party, so DivideExpDataByNumMonsGainingExp divides the already + -- halved-and-participant-divided exp again by the party count, and + -- .partyMonLoop still skips fainted mons (core.asm:818-858 + + -- experience.asm:9-13); each mon gets its own GainedText with the + -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no + -- summary line + for _, mon in ipairs(self.game.save.party) do + if mon.hp > 0 then + applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll") + end + end + end + self.participants = {} + + if self.kind == "trainer" then + if self.enemyIndex < #self.enemyParty then + self.enemyIndex = self.enemyIndex + 1 + -- SHIFT battle style (the default): announce the next mon and + -- offer a free switch (SET skips the prompt) + local nextMon = self.enemyParty[self.enemyIndex] + local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name + local style = (self.game.save.options or {}).battleStyle or "shift" + local healthy = 0 + for _, mon in ipairs(self.game.save.party) do + if mon.hp > 0 then healthy = healthy + 1 end + end + if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then + self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName)) + self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name)) + local game = self.game + self:ui(function() + local ChoiceBox = require("src.ui.ChoiceBox") + return ChoiceBox.new(game, function(yes) + if not yes then return end + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game, { + battle = self, + onSwitch = function(mon) + if mon ~= self.player.mon and mon.hp > 0 then + self.player = makeBattler(self.data, mon, true, game.save) + self:markParticipant() + self.nextInsert = 0 + self.sendingOut = true + self:sayNext(self:sendOutText(self.player.name)) + self:animNext("POOF_ANIM", false) + self:actNext(function() + self.sendingOut = false + -- SendOutMon (core.asm:1757-1762): poof, then the grow-in + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + end + end, + })) + end) + end) + end + self:act(function() + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.aiUses = self:aiUsesFor() + markSeen(self.game, self.enemy.mon.species) + self:markParticipant() + -- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area + -- clears, TrainerSentOutText prints, THEN the pic appears + -- (AnimateSendingOutMon) with the cry; no POOF -- that animation + -- belongs to the player-side SendOutMon (core.asm:1757-1762) + self.enemySendingOut = true + self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + self:actNext(function() + self.enemySendingOut = false + self:startGrowIn(self.enemy) + self:actNext(function() + require("src.core.Sound").playCry(self.data, self.enemy.mon.species) + end) + end) + end) + return + end + local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level + self.game.save.money = self.game.save.money + prize + -- the beaten trainer's pic returns for the defeat text (pokered + -- DisplayBattleMenu's defeat flow) + self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end) + -- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then + -- the victory theme starts BEFORE TrainerDefeatedText and the + -- prize money + self:actNext(function() self:playVictoryMusic() end) + -- _TrainerDefeatedText: " defeated\nTRAINER!" + self:sayNext(("%s defeated\n%s!"):format(self.game.save.player.name, + self.trainer.name)) + self:sayNext(("%s got ¥%d\nfor winning!"):format(self.game.save.player.name, prize)) + end + self.result = "win" + self.afterQueue = "finish" +end + +-- Queue the learn-a-move flow (auto if a slot is free, else the forget UI) +function BattleState:learnMove(mon, moveId) + local mdef = self.data.moves[moveId] + if not mdef then return end + for _, mv in ipairs(mon.moves) do + if mv.id == moveId then return end + end + if #mon.moves < 4 then + table.insert(mon.moves, { id = moveId, pp = mdef.pp }) + self:sayNext(("%s learned\n%s!"):format(mon.nickname or self.data.pokemon[mon.species].name, + mdef.name)) + return + end + -- the "trying to learn" preamble lives inside MoveLearnMenu:enter; + -- ordered insert so multi-level gains keep each level's checks + -- between its own stat box and the next "grew to level" text + local game = self.game + self:uiNext(function() + local MoveLearnMenu = require("src.ui.MoveLearnMenu") + return MoveLearnMenu.new(game, mon, moveId) + end) +end + +function BattleState:playerMonFainted() + if self.result then return end -- double faint: the battle is decided + local nextMon = Party.firstHealthy(self.game.save.party) + if not nextMon then + self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name)) + self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name)) + self.result = "lose" + self.afterQueue = "finish" + return + end + -- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask + -- "Use next POKéMON?"; NO goes through the run check with party slot + -- 1's speed, and a failed run still forces the party menu. Trainer + -- battles go straight to the party menu (the menu-phase guard). + if self.kind ~= "wild" then return end + local game = self.game + self:say(self.data.text._UseNextMonText or "Use next POKéMON?") + self:ui(function() + local ChoiceBox = require("src.ui.ChoiceBox") + return ChoiceBox.new(game, function(yes) + if yes then return end -- the menu-phase guard opens the party menu + local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then + require("src.core.Sound").play(self.data, "Run") + self:say("Got away safely!") + self.result = "run" + self.afterQueue = "finish" + else + self:say("Can't escape!") + end + end) + end) +end + +-- ChooseNextMon (core.asm:1086-1128): the battle party menu; a fainted +-- pick re-prompts (via the menu-phase guard), a healthy pick is sent +-- out with no free enemy move. +function BattleState:openReplacementMenu() + local game = self.game + self.phase = "messages" + self.afterQueue = "menu" + self:ui(function() + local PartyMenu = require("src.ui.PartyMenu") + return PartyMenu.new(game, { + battle = self, + onSwitch = function(mon) + if mon.hp <= 0 then + self:say("There's no will\nto fight!") + return -- the menu-phase guard reopens the menu + end + self:restoreMimicked(self.player) + self.player = makeBattler(self.data, mon, true, game.save) + self:markParticipant() + self.nextInsert = 0 + self.sendingOut = true + self:sayNext(self:sendOutText(self.player.name)) + self:animNext("POOF_ANIM", false) + self:actNext(function() + self.sendingOut = false + -- SendOutMon (core.asm:1757-1762): poof, then the grow-in + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + end, + }) + end) +end + +-- --------------------------------------------------------------------- +-- Safari game turns +-- --------------------------------------------------------------------- + +-- BAIT halves the working catch rate and raises the bait factor by 1-5 +-- (zeroing the escape factor); ROCK doubles the catch rate and raises +-- the escape factor by 1-5 (zeroing bait) -- ItemUseBait/ItemUseRock, +-- engine/items/item_effects.asm. +function BattleState:safariAction(choice) + self.phase = "messages" + self.afterQueue = "menu" + local st = self.safari + local playerName = self.game.save.player.name + + if choice == "run" then + self:say("Got away safely!") + self.result = "run" + self.afterQueue = "finish" + return + end + + if choice == "ball" then + st.balls = st.balls - 1 + self:say(("%s used\nSAFARI BALL!"):format(playerName)) + self:act(function() + require("src.core.Sound").play(self.data, "Ball_Toss") + local caught, shakes = Catching.attempt("SAFARI_BALL", self.enemy.mon, + self.enemy.def, self.rng, + self.safariCatchRate) + -- SAFARI_BALL is neither POKE nor GREAT, so TossBallAnimation + -- lands on the ULTRATOSS arc (no flicker: SAFARI_BALL is $08, + -- above DoBallTossSpecialEffects's <= ULTRA_BALL check) + self:ballChain("ULTRATOSS_ANIM", caught, shakes, "SAFARI_BALL") + if caught then + -- ItemUseBallText05's sound_caught_mon: fanfare with the text + self:actNext(function() + require("src.core.Sound").play(self.data, "Caught_Mon") + end) + self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + -- same ItemUseBall .captured flow as a regular ball + self:act(function() self:storeCaughtMon() end) + else + self:sayNext(self:ballMissMessage(shakes)) + self:act(function() self:safariEnemyTurn() end) + end + end) + return + end + + if choice == "bait" then + self:say(("%s threw some\nBAIT."):format(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(("%s threw a\nROCK."):format(playerName)) + self.safariCatchRate = math.min(255, self.safariCatchRate * 2) + self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5)) + self.baitFactor = 0 + end + self:act(function() self:safariEnemyTurn() end) +end + +-- Per-turn factor decay (PrintSafariZoneBattleText, +-- engine/battle/safari_zone.asm: when the escape factor runs out the +-- catch rate resets) then the flee check (engine/battle/core.asm: +-- b = 2*speed, quartered while eating, doubled while angry; the mon +-- flees when speed > 127 or rand(0,255) < b). +function BattleState:safariEnemyTurn() + if self.baitFactor > 0 then + self.baitFactor = self.baitFactor - 1 + self:sayNext(("Wild %s\nis eating!"):format(self.enemy.name)) + elseif self.escapeFactor > 0 then + self.escapeFactor = self.escapeFactor - 1 + if self.escapeFactor == 0 then + self.safariCatchRate = self.enemy.def.catchRate + end + self:sayNext(("Wild %s\nis angry!"):format(self.enemy.name)) + end + self:act(function() + local speed = self.enemy.curStats.speed % 256 + local fled = speed > 127 + local b = (speed * 2) % 256 + if not fled then + if self.baitFactor > 0 then + b = math.floor(b / 4) + end + if self.escapeFactor > 0 then + b = math.min(255, b * 2) + end + fled = self.rng(0, 255) < b + end + if fled then + self:sayNext(("Wild %s\nran!"):format(self.enemy.name)) + self.result = "run" + self.afterQueue = "finish" + end + end) +end + +-- --------------------------------------------------------------------- +-- run / items / party +-- --------------------------------------------------------------------- + +-- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle), +-- shared by the RUN menu choice and the faint dialogue's NO branch; +-- counts a run attempt each call. +function BattleState:runRoll(pSpd, eSpd) + self.runAttempts = (self.runAttempts or 0) + 1 + if self.ghost then + return true -- IsGhostBattle -> always escapes + end + if pSpd >= eSpd then return true end + local b = math.floor(eSpd / 4) % 256 + if b == 0 then + return true -- divisor of zero auto-escapes + end + local x = math.floor(pSpd * 32 / b) + -- +30 per PREVIOUS attempt, escape on 8-bit overflow or on + -- rand <= x (the original's jr nc keeps the equal case) + x = x + 30 * (self.runAttempts - 1) + return x >= 256 or self.rng(0, 255) <= x +end + +-- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle) +function BattleState:tryRun() + self.phase = "messages" + self.afterQueue = "menu" + if self.kind == "trainer" then + self:say("No! There's no\nrunning from a\ntrainer battle!") + return + end + -- modified in-battle speeds (stat stages + paralysis), like the + -- wBattleMonSpeed the original hands to TryRunningFromBattle + local escaped = self:runRoll(TurnOrder.effectiveSpeed(self.player), + TurnOrder.effectiveSpeed(self.enemy)) + if escaped then + require("src.core.Sound").play(self.data, "Run") + self:say("Got away safely!") + self.result = "run" + self.afterQueue = "finish" + else + self:say("Can't escape!") + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) + end +end + +function BattleState:openItems() + local BagMenu = require("src.ui.BagMenu") + local game = self.game + self.phase = "messages" + self.afterQueue = "menu" + self:ui(function() + return BagMenu.new(game, { battle = self }) + end) +end + +-- called by BagMenu after an item is used in battle (consumes the turn) +function BattleState:itemUsed(messages) + for _, m in ipairs(messages or {}) do self:say(m) end + table.insert(self.queue, { drain = true }) -- potions animate the bar + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) +end + +-- Wobble messages by shake count (ItemUseBallText01..04) +function BattleState:ballMissMessage(shakes) + local t = self.data.text + if shakes == 0 then + return t._ItemUseBallText01 or "You missed the\nPOKéMON!" + elseif shakes == 1 then + return t._ItemUseBallText02 or "Darn! The POKéMON\nbroke free!" + elseif shakes == 2 then + return (t._ItemUseBallText03 or "Aww! It appeared\nto be caught!"):gsub("%s+$", "") + end + return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" +end + +-- The caught mon joins the party or a PC box (ItemUseBall .captured, +-- item_effects.asm:518-566): the caught text, then for a NEW species +-- "New POKéDEX data will be added" + the dex entry page, then the +-- party add (with the nickname ask) or the PC transfer text. +function BattleState:storeCaughtMon() + -- ItemUseBall reloads the caught mon via LoadEnemyMonData + -- (item_effects.asm:472-501), regenerating its move list from the + -- base data -- a Mimic'd slot never leaves the battle with it + self:restoreMimicked(self.enemy) + local game = self.game + local dex = game.save.pokedex + local species = self.enemy.mon.species + local isNew = dex ~= nil and not dex.owned[species] + markOwned(game, species) + stampOT(game.save, self.enemy.mon) + if isNew then + -- _ItemUseBallText06 + ShowPokedexData + self:sayNext(("New POKéDEX data\nwill be added for\n%s!"):format(self.enemy.name)) + self:uiNext(function() + local DexEntryMenu = require("src.ui.DexEntryMenu") + return DexEntryMenu.new(game, species) + end) + end + if Party.add(game.save.party, self.enemy.mon) then + -- nickname prompt (AskName runs inside AddPartyMon; box mons are + -- never offered a nickname) + local caught = self.enemy.mon + local enemyName = self.enemy.name + self:uiNext(function() + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?") + :format(enemyName), function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + local ok, NamingScreen = pcall(require, "src.ui.NamingScreen") + if not ok then return end + game.stack:push(NamingScreen.new(game, { + title = "NICKNAME?", maxLen = 10, + onDone = function(name) + if name and #name > 0 then caught.nickname = name end + end, + })) + end)) + end) + end) + else + local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) + if boxNum then + -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL + local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) + and "BILL's PC" or "someone's PC" + self:sayNext(("%s was\ntransferred to\n%s!"):format(self.enemy.name, pc)) + else + self:sayNext("But every BOX\nis full!") + end + end + self.result = "caught" + self.afterQueue = "finish" +end + +-- TossBallAnimation (engine/battle/animations.asm:2582): the tier's toss +-- anim, then wPokeBallAnimData's upper-nybble count of .PokeBallAnimations +-- entries -- POOF+HIDEPIC+SHAKE for a capture ($43), all five (plus a +-- reappearing POOF+SHOWPIC) for a breakout ($6x); a clean miss ($20) +-- stops after the poof, so the mon never hides +function BattleState:ballChain(tossAnim, caught, shakes, ball) + self:animNext(tossAnim, true, nil, ball) + self:animNext("POOF_ANIM", true) + if not caught and shakes == 0 then return end + self:animNext("HIDEPIC_ANIM", true) + self:animNext("SHAKE_ANIM", true, shakes) + if not caught then + self:animNext("POOF_ANIM", true) + self:animNext("SHOWPIC_ANIM", true) + return + end + -- on a capture the $43 chain simply ends after SHAKE_ANIM + -- (TossBallAnimation returns): the GB leaves the resting closed ball + -- in OAM, so it stays on screen through the caught text + self:actNext(function() + self.lockedBall = self.animPlayer and self.animPlayer:finalSprites() or nil + end) +end + +-- TossBallAnimation picks the toss arc from wCurItem: POKE->TOSS, +-- GREAT->GREATTOSS, everything else (ULTRA/MASTER/SAFARI...)->ULTRATOSS +local function tossAnimFor(ball) + return ball == "POKE_BALL" and "TOSS_ANIM" + or ball == "GREAT_BALL" and "GREATTOSS_ANIM" + or "ULTRATOSS_ANIM" +end + +-- called by BagMenu when a ball is thrown +function BattleState:throwBall(ball) + self:say(("%s used\n%s!"):format(self.game.save.player.name, + self.data.items[ball].name)) + self:act(function() + require("src.core.Sound").play(self.data, "Ball_Toss") + if self.kind ~= "wild" then + self:sayNext("The TRAINER\nblocked the BALL!") + self:sayNext("Don't be a thief!") + return + end + if self.ghost then + -- ItemUseBall's can't-be-caught path (item_effects.asm:149-153): + -- the ball is thrown (TossBallAnimation still picks the arc from + -- wCurItem, so a Master/Ultra toss keeps its flicker), dodged + -- ($10 anim data, no wobbles), and the turn is spent like any + -- failed throw + self:animNext(tossAnimFor(ball), true, nil, ball) + self:sayNext("It dodged the\nthrown BALL!") + self:sayNext("This POKéMON\ncan't be caught!") + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) + return + end + local caught, shakes = Catching.attempt(ball, self.enemy.mon, + self.enemy.def, self.rng) + -- ItemUseBall's 20-frame beat, then the toss chain for the outcome + -- (TossBallAnimation maps POKE->TOSS, GREAT->GREATTOSS, else ULTRATOSS) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 20 }) + self:ballChain(tossAnimFor(ball), caught, shakes, ball) + if caught then + -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: + -- 608-614): the fanfare sounds with the caught message, before + -- the prompt, not after the text is dismissed + self:actNext(function() + require("src.core.Sound").play(self.data, "Caught_Mon") + end) + self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + self:act(function() self:storeCaughtMon() end) + else + self:sayNext(self:ballMissMessage(shakes)) + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) + end + end) +end + +function BattleState:openParty() + local PartyMenu = require("src.ui.PartyMenu") + local game = self.game + self.phase = "messages" + self.afterQueue = "menu" + self:ui(function() + return PartyMenu.new(game, { + battle = self, + onSwitch = function(mon) + if mon == self.player.mon then + self:say(("%s is\nalready out!"):format(self.player.name)) + elseif mon.hp <= 0 then + self:say("There's no will\nto fight!") + else + self:resolveSwitch(mon) + end + end, + }) + end) +end + +-- PlayBattleVictoryMusic (core.asm:959-967) + EndLowHealthAlarm +-- (core.asm:864-872): winning stops the low-health alarm and disables +-- it for the rest of the battle (wLowHealthAlarmDisabled), then starts +-- the victory theme once; gym leaders, Lance and the final rival share +-- MUSIC_DEFEATED_GYM_LEADER (core.asm:917-926). +function BattleState:playVictoryMusic() + require("src.core.Sound").stopLoop("Low_Health_Alarm") + self.lowHealthAlarmDisabled = true + if self.victoryMusicPlayed then return end + self.victoryMusicPlayed = true + local kind = self.musicKind == "final" and "gym" or (self.musicKind or "wild") + require("src.core.Music").playVictory(self.data, kind) +end + +function BattleState:finish() + if self.payDay and self.result == "win" then + self.game.save.money = self.game.save.money + self.payDay + self:say(("%s picked up\n¥%d!"):format(self.game.save.player.name, self.payDay)) + self.payDay = nil + self.afterQueue = "finish" + self.phase = "messages" + return + end + self.lockedBall = nil + -- pokered never writes Mimic's copy into the party struct; leaving + -- battle discards the battle copy, so the original ids come back + self:restoreMimicked(self.player) + self:restoreMimicked(self.enemy) + -- end_of_battle.asm clears wLowHealthAlarm at battle teardown + require("src.core.Sound").stopLoop("Low_Health_Alarm") + -- 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, + -- like the overworld reload's PlayDefaultMusicFadeOutCurrent + -- (home/overworld.asm:2343-2348) + require("src.core.Music").restoreMap(self.data) + self.game.stack:pop() + if self.onFinish then self.onFinish(self.result or "run") end +end + +-- --------------------------------------------------------------------- +-- draw +-- --------------------------------------------------------------------- + +-- In-battle HUD tiles + the tile HP bar live in src/render/HudTiles.lua +-- (shared with the status screen) +local HudTiles = require("src.render.HudTiles") +local hudTile = HudTiles.tile +local drawHPBar = HudTiles.drawHPBar + +-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile +local function nameX(tx, name) + local n = #name + return tx * 8 + (n <= 2 and 16 or n <= 4 and 8 or 0) +end + +-- Party pokeball row (SetupPokeballs tiles: ball / status ball / +-- fainted ball / empty), 6 slots stepping dx from (x,y). +local ballQuads +function BattleState:drawBallRow(party, x, y, dx) + if ballQuads == nil then + local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png") + if ok then + ballQuads = { img = img } + for i = 0, 3 do + ballQuads[i] = love.graphics.newQuad(i * 8, 0, 8, 8, img:getDimensions()) + end + else + ballQuads = false + end + end + if not ballQuads then return end + for i = 1, 6 do + local mon = party[i] + local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0 + love.graphics.draw(ballQuads.img, ballQuads[tile], x + (i - 1) * dx, y) + end +end + +-- the grow-in scale for a battler's pic this frame: nil when not +-- growing, else 0 (ball beat) / 3/7 / 5/7 -- AnimateSendingOutMon's +-- stages (core.asm:6801-6838): 3 frames of the ball tile, 4 frames of +-- a 3x3 block of the 7x7 pic tiles, 5 frames of 5x5, then full size +function BattleState:growInScale(battler) + local grow = self.growIn + if not grow or grow.battler ~= battler then return nil end + local f = grow.frame + return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7 +end + +-- battler hidden this frame? (damage blink) +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 % 8 < 4 + end + return false +end + +-- is the faint slide currently playing for this battler? +function BattleState:fxFaintActive(battler) + local fx = self.fx + return fx and fx.faint and fx.faint.battler == battler + and fx.faint.frames > 0 or false +end + +-- vertical slide offset for a fainting battler (the player's pic is +-- drawn 2x, so it slides 2x as fast to sink at the same visual rate) +function BattleState:fxFaintOffset(battler) + local fx = self.fx + if self:fxFaintActive(battler) then + return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1) + end + return 0 +end + +-- Substitute doll (AnimationSubstitute, engine/battle/animations.asm): +-- while a battler's substitute is up, its pic is replaced by the mini +-- doll from gfx/sprites/monster.png -- the facing-DOWN frame for the +-- enemy, facing-UP for the player, a 16x16 sprite at pic tiles +-- (2..3,4..5) / (3..4,4..5) of the 7x7 frame: screen (112,32) enemy, +-- (32,72) player. +local substDoll +function BattleState:drawSubstituteDoll(battler) + if substDoll == nil then + local ok, img = pcall(love.graphics.newImage, + "assets/generated/sprites/monster.png") + if ok then + local w, h = img:getDimensions() + substDoll = { img = img, + down = love.graphics.newQuad(0, 0, 16, 16, w, h), + up = love.graphics.newQuad(0, 16, 16, 16, w, h) } + else + substDoll = false + end + end + if not substDoll then return end + -- in colorized mode the doll (drawn from BG tiles on the GB) takes + -- its screen zone's SGB palette like everything else in the region + local shader + if self:colorMode() then + local PaletteFX = require("src.render.PaletteFX") + shader = PaletteFX.shader() + if shader then + local colors = self:zoneColorsAt(battler.isPlayer and 32 or 112, + battler.isPlayer and 72 or 32) + if colors then + love.graphics.setShader(shader) + PaletteFX.sendColors(shader, + require("src.render.PaletteFX").permute(colors, self:activeBgp())) + else + shader = nil + end + end + end + if battler.isPlayer then + love.graphics.draw(substDoll.img, substDoll.up, 32, 72) + else + love.graphics.draw(substDoll.img, substDoll.down, 112, 32) + end + if shader then love.graphics.setShader() end +end + +-- MinimizedMonSprite (animations.asm:1745): the 8x5 blob that replaces +-- a minimized mon's pic, written at pic tile (3,4)+2px. Rows are bit +-- patterns, drawn as shade-3 pixels. +local MINIMIZED_ROWS = { + { 3, 4 }, -- ...XX... + { 2, 5 }, -- ..XXXX.. + { 1, 6 }, -- .XXXXXX. + { 2, 5 }, -- ..XXXX.. + { 2, 2, 5, 5 }, -- ..X..X.. +} +function BattleState:drawMinimizedBlob(battler, x, y) + local r, g, b, a = love.graphics.getColor() + local col = { 0, 0, 0, 1 } + local pals = self:colorMode() and self:sgbBattlePals() + if pals then + local P = pals[battler.isPlayer and 2 or 3] + local shade = P[4] + col = { shade[1] / 255, shade[2] / 255, shade[3] / 255, 1 } + end + love.graphics.setColor(col) + for row, runs in ipairs(MINIMIZED_ROWS) do + for i = 1, #runs, 2 do + love.graphics.rectangle("fill", x + 24 + runs[i], y + 34 + row - 1, + runs[i + 1] - runs[i] + 1, 1) + end + end + love.graphics.setColor(r, g, b, a) +end + +-- Draw a battler pic, sinking it behind its own baseline while the +-- faint slide plays (pokered's AnimationSlideMonDown); a fainted +-- battler stays hidden once the slide ends. A standing substitute +-- shows the mini doll instead of the mon's own pic. The SE-driven +-- pic effects (slides/squish/blink/minimize; see applyAnimEffect) +-- offset, clip or replace the pic, and an active BGP fade swaps in a +-- shade-remapped recolor of it. +function BattleState:drawBattlerPic(battler, x, y, scale) + local img = self:picImage(battler.sprite) + if battler.substituteHP and not self:fxFaintActive(battler) + and not battler.fainted then + self:drawSubstituteDoll(battler) + return + end + if self:fxFaintActive(battler) then + local off = self:fxFaintOffset(battler) + local visible = img:getHeight() - math.floor(off / scale) + if visible > 0 then + local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible, + img:getWidth(), img:getHeight()) + love.graphics.draw(img, quad, x, y + off, 0, scale, scale) + end + return + end + if battler.fainted then return end + + 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) + return + end + if pf.hidden then return end + if pf.minimized then + self:drawMinimizedBlob(battler, x, y) + return + end + + local w, h = img:getWidth(), img:getHeight() + local ox, oy = pf.ox or 0, pf.oy or 0 + local k, t = pf.kind, pf.t or 0 + local xscale = 1 + -- while an SE effect displaces the pic, confine it to its side's + -- tile window like the GB tilemap does (the pic can never overwrite + -- the HUD columns or the text box rows) + local clip = love.graphics.setScissor and love.graphics.intersectScissor + local scx, scy, scw, sch + if clip then + scx, scy, scw, sch = love.graphics.getScissor() + if battler.isPlayer then + love.graphics.intersectScissor(0, 0, 80, 96) + else + love.graphics.intersectScissor(88, 0, 72, 56) + end + end + if k == "slideOff" then + -- one tile (8px) toward the mon's own screen edge per 3 frames + local dir = battler.isPlayer and -1 or 1 + ox = ox + dir * 8 * math.min(8, math.floor(t / 3) + 1) + elseif k == "slideHalf" then + local dir = battler.isPlayer and -1 or 1 + ox = ox + dir * 8 * math.min(4, math.floor(t / 4) + 1) + elseif k == "slideDown" then + oy = oy + 8 * math.min(7, math.floor(t / 3) + 1) + elseif k == "slideDownHide" then + oy = oy + 16 * (math.floor(t / 8) + 1) + elseif k == "bounce" then + -- 5 back-to-back AnimationSlideMonDown passes + oy = oy + 8 * math.min(7, math.floor((t % 21) / 3) + 1) + elseif k == "shakeBF" then + ox = ox + ((math.floor(t / 3) % 2 == 0) and -8 or 8) + elseif k == "squish" then + xscale = math.max(0, 7 - 2 * (math.floor(t / 6) + 1)) / 7 + elseif k == "blink" then + -- skip; falls through to the scissor-restore below instead of an + -- early return that would leave the pic-window scissor stuck + end + + local skipDraw = (k == "squish" and xscale <= 0) + or (k == "blink" and math.floor(t / 5) % 2 == 0) + + if skipDraw then + -- draw nothing this frame, but still restore the scissor rect + elseif oy > 0 then + -- sink below the baseline (AnimationSlideMonDown-style row clip) + local visible = h - math.floor(oy / scale) + if visible > 0 then + local quad = love.graphics.newQuad(0, 0, w, visible, w, h) + love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) + end + elseif k == "slideUp" then + -- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames + local scroll = 8 * math.min(7, math.floor(t / 2) + 1) + local src = math.floor(scroll / scale) % h + if src == 0 then + love.graphics.draw(img, x + ox, y, 0, scale, scale) + else + local top = love.graphics.newQuad(0, src, w, h - src, w, h) + love.graphics.draw(img, top, x + ox, y, 0, scale, scale) + local bottom = love.graphics.newQuad(0, 0, w, src, w, h) + love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale, + 0, scale, scale) + end + elseif xscale < 1 then + -- AnimationSquishMonPic: columns collapse toward the middle + love.graphics.draw(img, x + w * scale * (1 - xscale) / 2, y, + 0, scale * xscale, scale) + else + love.graphics.draw(img, x + ox, y + oy, 0, scale, scale) + end + if clip then + if scx then + love.graphics.setScissor(scx, scy, scw, sch) + else + love.graphics.setScissor() + end + end +end + +-- ------------------------------------------------------------------ +-- SGB battle colorization. SetPal_Battle (engine/gfx/palettes.asm:28) +-- assigns pal 0 = player HP-bar palette, pal 1 = enemy HP-bar palette, +-- pal 2 = player mon palette, pal 3 = enemy mon palette; +-- BlkPacket_Battle (data/sgb/sgb_packets.asm:65) maps them onto screen +-- regions. The BG layer is drawn in DMG grays to a canvas and each +-- region is recolored through the PaletteFX shader; the OAM anim +-- sprites are colored per sprite afterwards (BGP fades never touch +-- them, matching the hardware). +-- ------------------------------------------------------------------ + +-- BlkPacket_Battle ATTR_BLK data: pal slot + inclusive tile rect. +-- The first entry is the %111 outside fill; the blocks are disjoint. +local BATTLE_ZONES = { + { pal = 0, 0, 0, 19, 17 }, -- everything else + { pal = 1, 1, 0, 10, 3 }, -- enemy HUD + { pal = 0, 10, 7, 19, 10 }, -- player HUD + { pal = 2, 0, 4, 8, 11 }, -- player mon + { pal = 3, 11, 0, 19, 6 }, -- enemy mon + { pal = 2, 0, 12, 19, 17 }, -- message box +} + +-- the colorizer needs canvases + shaders + pixel access (headless +-- stubs and stripped-down builds fall back to the flat colored path) +function BattleState:colorMode() + if self.colorFxReady == nil then + local ready = false + local g = love and love.graphics + if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas + and love.image and self.data.palettes + and require("src.render.PaletteFX").shader() then + local ok1, bg = pcall(g.newCanvas, 160, 144) + local ok2, wv = pcall(g.newCanvas, 160, 144) + if ok1 and ok2 and bg and wv then + self.bgCanvas, self.waveCanvas = bg, wv + ready = true + end + end + self.colorFxReady = ready + end + return self.colorFxReady +end + +-- The four SGB palettes SetPal_Battle would currently send: bar +-- palettes track the drawn HP bars (GetHealthBarColor), the mon slots +-- hold MonsterPalettes[wBattleMonSpecies]/[wEnemyMonSpecies2] -- +-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its +-- trainer/back pic (the species bytes are 0 then). +function BattleState:sgbBattlePals() + local pals = self.data.palettes and self.data.palettes.palettes + if not pals then return nil end + local PaletteFX = require("src.render.PaletteFX") + local function bar(b) + if not b then return pals.GREENBAR end + local hp = b.shownHP or b.mon.hp + return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR + end + local function mon(b, placeholder) + if placeholder or not b then return pals.MEWMON or pals.GREENBAR end + local name = self.data.palettes.pokemon[b.mon.species] + return pals[name] or pals.MEWMON + end + return { + [0] = bar(self.player), + [1] = bar(self.enemy), + [2] = mon(self.player, self.showPlayerBack or self.safari or self.demo), + [3] = mon(self.enemy, self.showEnemyTrainer), + } +end + +-- the SGB palette covering a screen pixel (BlkPacket_Battle regions) +function BattleState:zoneColorsAt(x, y) + local pals = self:sgbBattlePals() + if not pals then return nil end + local tx = math.floor(x / 8) + local ty = math.floor(y / 8) + if ty >= 12 then return pals[2] end -- message box + if tx >= 11 and ty <= 6 then return pals[3] end -- enemy mon + if tx <= 8 and ty >= 4 and ty <= 11 then return pals[2] end -- player mon + if tx >= 1 and tx <= 10 and ty <= 3 then return pals[1] end -- enemy HUD + return pals[0] +end + +-- AnimationWavyScreen's per-scanline SCX offsets +-- (WavyScreenLineOffsets, animations.asm:1926) +local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, + 0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2, + -1, -1, -1 } + +-- wave the BG canvas one scanline at a time; the offset table walks +-- one entry per frame like the asm's advancing pointer +function BattleState:applyWavy(src) + local wavy = self.fx and self.fx.wavy + if not wavy then return src end + local g = love.graphics + local prev = g.getCanvas() + g.setCanvas(self.waveCanvas) + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + self.waveQuad = self.waveQuad or g.newQuad(0, 0, 160, 1, 160, 144) + for line = 0, 143 do + self.waveQuad:setViewport(0, line, 160, 1) + g.draw(src, self.waveQuad, + WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line) + end + g.setCanvas(prev) + return self.waveCanvas +end + +-- recolor the grayscale BG canvas per zone; an active BGP fade permutes +-- the zone palette (the SGB colors the remapped DMG shade). A window +-- shake draws a second, offset copy over the base one: the color +-- regions themselves never move on the SGB, and the vacated strip +-- shows the unshifted BG map like the hardware. +function BattleState:drawZonePass(src, sx, sy) + local PaletteFX = require("src.render.PaletteFX") + local shader = PaletteFX.shader() + local pals = self:sgbBattlePals() + local bgp = self:activeBgp() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setShader(shader) + for _, z in ipairs(BATTLE_ZONES) do + PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp)) + love.graphics.setScissor(z[1] * 8, z[2] * 8, + (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8) + love.graphics.draw(src, 0, 0) + if sx ~= 0 or sy ~= 0 then + love.graphics.draw(src, sx, sy) + end + end + love.graphics.setScissor() + love.graphics.setShader() +end + +-- colors for one anim-layer OAM sprite at screen pixel (px, py): the +-- zone palette under that pixel's 8x8 attribute cell (the SGB colors +-- the composited picture per cell, so AnimPlayer samples once per cell +-- the tile overlaps), through the OBJ palette the routine ran with +-- (SetAnimationPalette: wAnimPalette = $f0 on SGB, rOBP1 = $6c, +-- ambient rOBP0 = $e4) +local OBJ_SHADES = { + f0 = { 0, 3, 3 }, -- color 1 -> shade 0, colors 2/3 -> shade 3 + f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc: the Master/Ultra ball + -- toss flicker (DoBallTossSpecialEffects) + e4 = { 1, 2, 3 }, -- identity + obp1 = { 3, 2, 1 }, -- $6c +} +function BattleState:animSpriteColors(s, px, py) + local P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4)) + if not P then return nil end + local m = OBJ_SHADES[s.obp or "f0"] or OBJ_SHADES.f0 + local function c(shade) + local col = P[shade + 1] + return { col[1] / 255, col[2] / 255, col[3] / 255 } + end + return { c(m[1]), c(m[2]), c(m[3]) } +end + +-- the OAM anim layer (subanimation sprites / the resting caught ball) +function BattleState:drawAnimLayer(colorized) + local colorFn + if colorized then + colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end + end + if self.animPlaying and self.animPlayer then + love.graphics.setColor(1, 1, 1, 1) + pcall(self.animPlayer.draw, self.animPlayer, colorFn) + elseif self.lockedBall and self.animPlayer then + -- the resting closed ball stays on screen through the caught text + -- (the $43 chain ends after SHAKE_ANIM and the GB never clears the + -- ball's OAM entries until the battle screen is torn down) + love.graphics.setColor(1, 1, 1, 1) + pcall(self.animPlayer.drawSprites, self.animPlayer, self.lockedBall, + colorFn) + end +end + +-- the two mon pics (or the trainer/back pics), offset by the window +-- shake -- on the GB the pics are BG tiles, so they move with it +function BattleState:drawPicsLayer(slide, sx, sy) + -- The move-select boxes are BG tiles on the GB, so they REPLACE the + -- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes + -- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu + -- .mimicmenu) wipes rows 7+. The port draws pics above the menu + -- layer in the colorized pipeline, so clip them to the visible rows. + local g = love.graphics + local clipY = self.phase == "mimicSelect" and 56 + or self.phase == "moveSelect" and 64 or nil + local clipped, cs1, cs2, cs3, cs4 + if clipY and g.getScissor and g.intersectScissor then + cs1, cs2, cs3, cs4 = g.getScissor() + g.intersectScissor(0, 0, 160, clipY) + clipped = true + end + -- Enemy: front sprite top-right (GB: pic at hlcoord 12,0). + if self.showEnemyTrainer and self.trainerPic then + -- the enemy trainer pic holds the mon slot until the send-out + local img = self:picImage(self.trainerPic) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx, + math.max(0, 48 - img:getHeight()) + sy) + elseif self.enemy and self.enemy.sprite and not self.enemyHidden + and not self.enemySendingOut and not self:fxHidden(self.enemy) then + local img = self:picImage(self.enemy.sprite) + love.graphics.setColor(1, 1, 1, 1) + local ex = 160 - 8 - img:getWidth() - slide + sx + local ey = math.max(0, 48 - img:getHeight()) + sy + local gs = self:growInScale(self.enemy) + if gs then + -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge + -- and horizontal center pinned to the mon's slot while it grows + if gs > 0 then + love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2, + ey + img:getHeight() * (1 - gs), 0, gs, gs) + end + else + self:drawBattlerPic(self.enemy, ex, ey, 1) + end + end + + -- Player: back sprite bottom-left (2x like the GB, feet near y=100). + local hidePlayer = self.safari or self.demo + if self.showPlayerBack and self.playerBackPic then + -- Red's (or the old man's) back pic until "Go!"; it stays up for + -- the whole safari / catch-demo battle like the original + local img = self:picImage(self.playerBackPic) + local pad = imagePadBottom[self.playerBackPic] or 0 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 16 + slide + sx, + 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) + elseif self.player and self.player.sprite and not hidePlayer + and not self.sendingOut and not self:fxHidden(self.player) then + local img = self:picImage(self.player.sprite) + love.graphics.setColor(1, 1, 1, 1) + -- feet flush on the text box top (y=96), ignoring baked-in padding + local pad = imagePadBottom[self.player.sprite] or 0 + local gs = self:growInScale(self.player) + if gs then + -- the player-side AnimateSendingOutMon grow (after the poof, + -- core.asm:1757-1762): feet pinned at y=96, center at x=16+w + if gs > 0 then + love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx, + 96 - (img:getHeight() - pad) * 2 * gs + sy, + 0, 2 * gs, 2 * gs) + end + else + self:drawBattlerPic(self.player, 16 + sx, + 96 - (img:getHeight() - pad) * 2 + sy, 2) + end + end + if clipped then + if cs1 then + g.setScissor(cs1, cs2, cs3, cs4) + else + g.setScissor() + end + end +end + +-- the BG-tile UI: HUDs, pokeball rows, safari ball count. Grayscale; +-- the zone pass colors it in colorized mode. +function BattleState:drawHUDs(slide) + -- the HUD clears with the send-out text (ClearScreenArea, + -- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws + -- it after the grow-in + cry + local barData = self:colorMode() and {} or self.data -- gray fill when zoned + local fx = self.fx + local hudShake = (fx and fx.hudShakeX) or 0 + if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + and not self:growInScale(self.enemy) and slide == 0 then + -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), + -- HP bar (2,2) with the vertical tick at (1,2), underline row 3; + -- AnimationShakeEnemyHUD nudges just this block via SCX + if hudShake ~= 0 then + love.graphics.push() + love.graphics.translate(hudShake, 0) + end + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) + if self.enemy.mon.status then + Font.draw(self.enemy.mon.status, 40, 8) + else + hudTile(0x6E, 32, 8) -- + Font.draw(tostring(self.enemy.mon.level), 40, 8) + end + hudTile(0x73, 8, 16) + drawHPBar(barData, 2, 2, + { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }) + hudTile(0x74, 8, 24) + for i = 2, 9 do hudTile(0x76, i * 8, 24) end + hudTile(0x78, 80, 24) + if hudShake ~= 0 then + love.graphics.pop() + end + end + + -- Safari shows only the ball count; the old man demo shows neither mon + if self.safari then + love.graphics.setColor(0, 0, 0, 1) + Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) + end + -- trainer-battle party pokeball rows during the intro + -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) + if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack) + and slide == 0 then + love.graphics.setColor(1, 1, 1, 1) + if self.showEnemyTrainer and self.enemyParty then + self:drawBallRow(self.enemyParty, 64, 16, -8) + end + if self.showPlayerBack then + self:drawBallRow(self.game.save.party, 88, 80, 8) + end + end + local hidePlayer = self.safari or self.demo + if self.player and not hidePlayer and not self.showPlayerBack + and slide == 0 then + -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level + -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with + -- the tick at (18,10) and the triangle at (9,11) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.player.name, nameX(10, self.player.name), 56) + if self.player.mon.status then + Font.draw(self.player.mon.status, 120, 64) + else + hudTile(0x6E, 112, 64) -- + Font.draw(tostring(self.player.mon.level), 120, 64) + end + drawHPBar(barData, 10, 9, + { hp = shownHP(self.player), stats = self.player.mon.stats }, + 1) -- wHPBarType 1: the $6D cap + Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) + hudTile(0x73, 144, 80) + hudTile(0x77, 144, 88) + for i = 10, 17 do hudTile(0x76, i * 8, 88) end + hudTile(0x6F, 72, 88) + end +end + +function BattleState:drawTextArea() + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if self.phase == "messages" and self.current then + local shown = 0 + for li, codes in ipairs(self.lines) do + local y = 104 + li * 8 + for i = 1, #codes do + if shown >= self.charIndex then break end + Font.drawCode(codes[i], 8 + (i - 1) * 8, y) + shown = shown + 1 + end + end + elseif self.phase == "menu" and self.demo then + -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the + -- standard menu, with the '▶' hand drawn by the scripted keystrokes + -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) + Font.drawBox(8, 12, 12, 6) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("FIGHT", 80, 112) + Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) + Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128) + Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) + elseif self.phase == "menu" then + local col = (self.menuIndex - 1) % 2 + local row = math.floor((self.menuIndex - 1) / 2) + if self.safari then + -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / + -- THROW ROCK RUN" from (2,14) + Font.drawBox(0, 12, 20, 6) + Font.draw("BALLx", 16, 112); Font.draw("BAIT", 112, 112) + Font.draw("THROW ROCK", 16, 128); Font.draw("RUN", 112, 128) + Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) + else + -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / + -- ITEM RUN" from (10,14); cursor columns 9 / 15 + Font.drawBox(8, 12, 12, 6) + Font.draw("FIGHT", 80, 112) + Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) + Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128) + Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) + end + elseif self.phase == "moveSelect" then + -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, + -- names at column 6 from row 13, cursor at column 5. PrintMenuItem: + -- the TYPE/PP box at (0,8) 11x5, with "TYPE/" at (1,9), the type at + -- (2,10) and "PP cur/max" at (5,11); its bottom border merges into + -- the move box's top border ('─' at (4,12), '┘' at (10,12)). + Font.drawBox(0, 8, 11, 5) + Font.drawBox(4, 12, 16, 6) + Font.drawCode(Font.BORDER.h, 32, 96) + Font.drawCode(Font.BORDER.br, 80, 96) + love.graphics.setColor(0, 0, 0, 1) + for i, mv in ipairs(self.player.curMoves) do + Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8) + end + Font.drawCode(0xED, 40, 96 + self.moveIndex * 8) + local sel = self.player.curMoves[self.moveIndex] + if sel then + if self.player.disabledSlot == self.moveIndex then + Font.draw("disabled!", 8, 80) + else + local def = self.data.moves[sel.id] + Font.draw("TYPE/", 8, 72) + Font.draw(def.type or "", 16, 80) + local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) + Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) + end + end + elseif self.phase == "mimicSelect" then + -- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm: + -- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names + -- single-spaced from (2,8), cursor at column 1 + Font.drawBox(0, 7, 16, 6) + love.graphics.setColor(0, 0, 0, 1) + for i, m in ipairs(self.mimicMoves) do + Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8) + end + Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) + end +end + +function BattleState:draw() + local fx = self.fx + -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); + -- the animations-off fallback keeps the old +-2 alternation + local sx = (fx and fx.shakeX) or 0 + local sy = (fx and fx.shakeY) or 0 + if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + local slide = (self.introSlide or 0) * 4 -- intro slide-in offset + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local prev = g.getCanvas() + local wavy = fx and fx.wavy + g.setCanvas(self.bgCanvas) + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + self:drawHUDs(slide) + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.setCanvas(prev) + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + self:drawAnimLayer(true) + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawAnimLayer(false) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + -- screen flash (flash-effect moves without the subanimation player): + -- white flicker overlay + if fx and fx.flash and fx.flash > 0 and self.frame % 4 < 2 then + love.graphics.setColor(1, 1, 1, 0.85) + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return BattleState diff --git a/src/battle/Catching.lua b/src/battle/Catching.lua new file mode 100644 index 00000000..1948d049 --- /dev/null +++ b/src/battle/Catching.lua @@ -0,0 +1,66 @@ +-- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall). + +local Catching = {} + +local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200, + ULTRA_BALL = 150, SAFARI_BALL = 150 } +local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12, + SAFARI_BALL = 12 } + +-- Returns caught, shakes (0-3). rateOverride replaces the species catch +-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate). +-- +-- On failure the ball wobbles per the original's shake calculation: +-- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10) +-- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3. +-- (We use the HP factor for X on both failure paths; the original reads +-- a stale quotient when the first roll fails.) +function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride) + rng = rng or love.math.random + if ball == "MASTER_BALL" then return true, 3 end + local randMax = BALL_RAND_MAX[ball] or 255 + local rate = rateOverride or targetDef.catchRate + + local statusBonus = 0 + local s = targetMon.status + if s == "SLP" or s == "FRZ" then + statusBonus = 25 + elseif s == "PSN" or s == "BRN" or s == "PAR" then + statusBonus = 12 + end + + -- HP factor (X) + local maxhp = targetMon.stats.hp + local hpQuarter = math.max(1, math.floor(targetMon.hp / 4)) + local factor = BALL_HP_FACTOR[ball] or 12 + -- the 255 cap applies only after BOTH divisions (ItemUseBall keeps + -- the intermediate in 16 bits); capping early collapses the value + local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter)) + + local function shakes() + local ballFactor2 = ball == "POKE_BALL" and 255 + or ball == "GREAT_BALL" and 200 or 150 + local y = math.floor(rate * 100 / ballFactor2) + local z + if y > 255 then + z = 255 + else + z = math.floor(f * y / 255) + end + if s == "SLP" or s == "FRZ" then + z = z + 10 + elseif s then + z = z + 5 + end + if z < 10 then return 0 elseif z < 30 then return 1 + elseif z < 70 then return 2 else return 3 end + end + + local r = rng(0, randMax) - statusBonus + if r < 0 then return true, 3 end + if r > rate then return false, shakes() end + if rng(0, 255) <= f then return true, 3 end + return false, shakes() +end + +return Catching diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua new file mode 100644 index 00000000..11f904a0 --- /dev/null +++ b/src/battle/Damage.lua @@ -0,0 +1,201 @@ +-- Gen 1 damage calculation, ported from engine/battle/core.asm +-- (GetDamage / CriticalHitTest / AdjustDamageForMoveType / RandomizeDamage). +-- +-- Battlers carry curStats/curTypes (Transform/Conversion can override the +-- species values) plus reflect/lightScreen/focusEnergy volatile flags. + +local Stats = require("src.pokemon.Stats") +local TypeChart = require("src.battle.TypeChart") + +local Damage = {} + +-- Moves with a boosted critical-hit rate (engine/battle/core.asm +-- CriticalHitTest checks these move ids explicitly). +local HIGH_CRIT = { + KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true, +} + +-- Critical chance test, following CriticalHitTest's shift chain exactly +-- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with +-- Focus Energy's famous right-shift bug), then x4 for high-crit moves +-- or /2 for normal ones. Net rates: normal = speed/512, high-crit = +-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual. +function Damage.critRoll(ruleset, attacker, moveId, rng) + rng = rng or love.math.random + local function shl(x) return math.min(255, x * 2) end + local b = math.floor(attacker.def.baseStats.speed / 2) + if attacker.focusEnergy then + if ruleset.focusEnergyBug then + b = math.floor(b / 2) -- srl instead of sla + else + b = shl(shl(shl(b))) -- intended: x4 the usual rate + end + else + b = shl(b) + end + if HIGH_CRIT[moveId] then + b = shl(shl(b)) + else + b = math.floor(b / 2) + end + return rng(0, 255) < b +end + +-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by +-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still +-- misses on 255. +function Damage.accuracyRoll(ruleset, move, attacker, defender, rng) + rng = rng or love.math.random + -- X ACCURACY sets USING_X_ACCURACY: the move simply never misses + -- (MoveHitTest returns before any accuracy math, 1/256 included) + if attacker.xAccuracy then return true end + local acc = math.floor(move.accuracy * 255 / 100) + -- CalcHitChance scales by the accuracy stage and the evasion stage as + -- two separate ratio multiplications, clamping each result + acc = math.min(255, Stats.applyStage(acc, + attacker.stages and attacker.stages.accuracy or 0)) + acc = math.min(255, Stats.applyStage(acc, + -(defender.stages and defender.stages.evasion or 0))) + if not ruleset.oneIn256Miss and move.accuracy >= 100 + and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then + return true + end + return rng(0, 255) < acc +end + +local function isSpecial(moveType) + -- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special + return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE" + or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE" + or moveType == "DRAGON" +end +Damage.isSpecial = isSpecial + +-- Compute damage. attacker/defender are battler tables. +-- opts: rng, forceCrit, explode (halves defense), typeless (confusion +-- self-hit: no STAB/type/random factor), screens (battler whose +-- Reflect/Light Screen apply when it isn't the defender -- the +-- self-hit reads the opponent's screens). +-- Returns damage, {crit=bool, typeMult=x10}. +function Damage.compute(ruleset, attacker, defender, move, opts) + opts = opts or {} + local rng = opts.rng or love.math.random + if move.power == 0 then + return 0, { crit = false, typeMult = 10 } + end + + local crit = opts.forceCrit + if crit == nil then + crit = Damage.critRoll(ruleset, attacker, move.id, rng) + end + + local special = isSpecial(move.type) + local atkStat = special and "special" or "attack" + local defStat = special and "special" or "defense" + + local atk, dfn + if crit and ruleset.critIgnoresStages then + atk = attacker.curStats[atkStat] + dfn = defender.curStats[defStat] + else + atk = Stats.applyStage(attacker.curStats[atkStat], + attacker.stages and attacker.stages[atkStat] or 0) + dfn = Stats.applyStage(defender.curStats[defStat], + defender.stages and defender.stages[defStat] or 0) + -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: + -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), + -- Volcano -> special + local badges = attacker.badges + if badges then + if not special and badges.BOULDERBADGE then + atk = math.floor(atk * 9 / 8) + elseif special and badges.VOLCANOBADGE then + atk = math.floor(atk * 9 / 8) + end + end + local dbadges = defender.badges + if dbadges then + if not special and dbadges.THUNDERBADGE then + dfn = math.floor(dfn * 9 / 8) + elseif special and dbadges.VOLCANOBADGE then + dfn = math.floor(dfn * 9 / 8) + end + end + -- burn halves physical attack (applied as part of the stat in Gen 1). + -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the + -- unmodified attack over the burn-halved battle stat, lifting the + -- penalty until the next stat recompute. + if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then + atk = math.max(1, math.floor(atk / 2)) + end + -- screens double the effective defense (crits bypass them). The + -- confusion self-hit is the quirk case: HandleSelfConfusionDamage + -- swaps the user's own defense in but leaves the screen check + -- reading the OPPONENT's battle status, so the typeless path takes + -- the screen flags from opts.screens (the opponent) and never from + -- the user itself. + if not crit then + local screens = opts.screens + if screens == nil and not opts.typeless then screens = defender end + if screens then + if special and screens.lightScreen then dfn = dfn * 2 end + if not special and screens.reflect then dfn = dfn * 2 end + end + end + end + -- GetDamageVars .scaleStats: when either stat no longer fits a byte, + -- BOTH are quartered (losing low bits), each bumped to at least 1 + if atk > 255 or dfn > 255 then + atk = math.max(1, math.floor(atk / 4)) + dfn = math.max(1, math.floor(dfn / 4)) + end + if opts.explode then + dfn = math.max(1, math.floor(dfn / 2)) + end + + local level = attacker.mon.level + if crit then level = level * 2 end + + local d = math.floor(math.floor(2 * level / 5) + 2) + d = math.floor(math.floor(d * move.power * atk / math.max(1, dfn)) / 50) + d = math.min(d, 997) + 2 + + local mult = 10 + if not opts.typeless then + -- STAB + local stab = false + for _, t in ipairs(attacker.curTypes) do + if t == move.type then stab = true break end + end + if stab then + d = math.floor(d * 3 / 2) + end + + -- type effectiveness: each TypeEffects row is applied to the + -- running damage separately with its own floor (0.5*0.5 lands on + -- floor(floor(d/2)/2), not d*0.25) + mult = TypeChart.effectiveness(move.type, defender.curTypes) + if mult == 0 then + return 0, { crit = false, typeMult = 0 } + end + for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + d = math.floor(d * m / 10) + end + if d == 0 then + -- a 2-3 damage hit at 0.25x floors to zero: the original flags + -- the move as missed rather than dealing a minimum 1 + return 0, { crit = false, typeMult = mult, missed = true } + end + end + + -- random factor; the typeless confusion self-hit skips RandomizeDamage + -- along with AdjustDamageForMoveType (HandleSelfConfusionDamage calls + -- CalculateDamage directly), so it is fully deterministic + if d > 1 and not opts.typeless then + local r = rng(ruleset.randMin, ruleset.randMax) + d = math.floor(d * r / 255) + end + return math.max(d, 1), { crit = crit, typeMult = mult } +end + +return Damage diff --git a/src/battle/Experience.lua b/src/battle/Experience.lua new file mode 100644 index 00000000..ebd9caf3 --- /dev/null +++ b/src/battle/Experience.lua @@ -0,0 +1,76 @@ +-- Experience gain (engine/battle/experience.asm): +-- exp = floor(baseExp * enemyLevel / 7) for a single participant +-- (trainer battles multiply by 1.5 in Gen 1) +-- Stat experience: the defeated species' base stats are added to each +-- participant's stat exp. + +local Growth = require("src.pokemon.Growth") +local Stats = require("src.pokemon.Stats") + +local Experience = {} + +-- engine/battle/experience.asm order: baseExp is divided by the +-- participant count FIRST, then *level/7, then the traded x1.5 +-- (BoostExp) and finally the trainer x1.5. +-- +-- EXP.ALL (core.asm .halveExpDataLoop): the base values are halved, +-- GainExperience runs for the participants, then reruns for the whole +-- party -- and because DivideExpDataByNumMonsGainingExp divides the +-- base values IN PLACE, the second pass inherits the participant +-- division: each party member gets (base/2)/participants/partyCount. +-- Sequential floor divisions equal one floor division by the product, +-- so callers pass numParticipants = 2*participants for the first pass +-- and 2*participants*partyCount for the whole-party pass. +function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded) + local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1)) + local exp = math.floor(base * level / 7) + if traded then + exp = math.floor(exp * 3 / 2) + end + if isTrainer then + exp = math.floor(exp * 3 / 2) + end + return math.max(1, exp) +end + +-- Applies exp/stat exp; returns the list of levels gained plus the raw +-- exp delta (wExpAmountGained, printed by _ExpPointsText -- captured +-- before the max-level cap, experience.asm:92-100). +function Experience.apply(data, mon, defeatedDef, level, isTrainer, + numParticipants, traded) + local speciesDef = data.pokemon[mon.species] + -- stat exp is divided among participants too + -- (DivideExpDataByNumMonsGainingExp divides wEnemyMonBaseStats) + local statShare = math.max(1, numParticipants or 1) + for _, key in ipairs(Stats.ORDER) do + local gain = math.floor(defeatedDef.baseStats[key] / statShare) + mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain) + end + local gained = Experience.gainFor(defeatedDef, level, isTrainer, + numParticipants, traded) + mon.exp = mon.exp + gained + + local levels = {} + local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp) + while mon.level < math.min(newLevel, 100) do + mon.level = mon.level + 1 + local old = mon.stats + mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp) + mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp)) + table.insert(levels, mon.level) + end + return levels, gained +end + +-- Moves learned when reaching exactly `level`. +function Experience.movesLearnedAt(speciesDef, level) + local out = {} + for _, entry in ipairs(speciesDef.learnset) do + if entry.level == level then + table.insert(out, entry.move) + end + end + return out +end + +return Experience diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua new file mode 100644 index 00000000..e69c3b72 --- /dev/null +++ b/src/battle/MoveEffects.lua @@ -0,0 +1,427 @@ +-- Move effect handlers for every effect constant used in +-- data/moves/moves.asm, ported from engine/battle/core.asm and +-- engine/battle/move_effects/*. Handlers receive the battle plus user and +-- target battler tables and push messages through battle:sayNext. +-- +-- Substitutes block status/stat effects and side effects aimed at their +-- owner, like Gen 1. + +local Logger = require("src.core.Logger") + +local MoveEffects = {} + +-- pokered's / text macros print "Enemy " before the +-- enemy mon's nickname (home/text.asm PlaceMoveUsersName) +local function displayName(b) + return b.isPlayer and b.name or ("Enemy " .. b.name) +end + +local STAT_LABEL = { + attack = "ATTACK", defense = "DEFENSE", speed = "SPEED", + special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE", +} + +-- --------------------------------------------------------------------- +-- stat stages +-- --------------------------------------------------------------------- + +local function changeStage(battle, who, stat, delta, fromEnemy) + if fromEnemy and (who.substituteHP or who.mist) then + if who.mist then + return { displayName(who) .. " is\nprotected by MIST!" } + end + return { "But, it failed!" } + end + local cur = who.stages[stat] or 0 + local new = math.max(-6, math.min(6, cur + delta)) + if new == cur then + return { ("Nothing happened!") } + end + who.stages[stat] = new + -- effects.asm:505-506: after any stat-stage change, modified stats are + -- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run, + -- re-baking the burn/para penalty and ending Haze's temporary lift. + who.hazeStatReset = nil + -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the + -- two-stage variants scroll "greatly" onto a third line + if delta >= 2 then + return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) } + elseif delta == 1 then + return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) } + elseif delta == -1 then + return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) } + end + return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) } +end + +local function statUp(stat, delta) + return function(battle, user, target) + return changeStage(battle, user, stat, delta, false) + end +end + +local function statDown(stat, delta) + return function(battle, user, target) + return changeStage(battle, target, stat, -delta, true) + end +end + +-- --------------------------------------------------------------------- +-- status +-- --------------------------------------------------------------------- + +local STATUS_LABEL = { + SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned", + FRZ = "was frozen solid", +} + +-- opts: toxic (start the Toxic counter), moveType (for the type +-- gates), secondary (side-effect of a damaging move). +local function inflictStatus(battle, target, status, opts) + opts = opts or {} + if target.mon.status then return {} end + -- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute) + -- and every secondary status, but NOT primary Sleep or Thunder Wave, + -- their handlers never check the substitute in Gen 1. + if target.substituteHP and (opts.secondary or status == "PSN") then + return {} + end + for _, t in ipairs(target.curTypes) do + -- can't poison Poison-types (primary or secondary) + if status == "PSN" and t == "POISON" then return {} end + -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types + if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then + return {} + end + -- FreezeBurnParalyzeEffect: a secondary status never lands when + -- the move's type matches either of the target's types (Body Slam + -- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) + if opts.secondary and status ~= "PSN" and opts.moveType == t then + return {} + end + -- keep the canonical immunities for any non-secondary path + if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then + return {} + end + end + target.mon.status = status + if status == "SLP" then + target.sleepTurns = battle.rng(1, 7) + end + if opts.toxic then + target.toxicCounter = 1 + -- _BadlyPoisonedText + return { ("%s's\nbadly poisoned!"):format(displayName(target)) } + end + if status == "PAR" then + -- _ParalyzedMayNotAttackText (primary and secondary paralysis) + return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) } + end + return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) } +end + +local function statusMove(status) + return function(battle, user, target, move) + if target.mon.status then + return { "But, it failed!" } + end + if status == "PSN" and target.substituteHP then + return { "But, it failed!" } + end + local msgs = inflictStatus(battle, target, status, { + toxic = move and move.id == "TOXIC", + moveType = move and move.type, + }) + if #msgs == 0 then + return { "But, it failed!" } + end + return msgs + end +end + +local function statusSide(status, chance) + return function(battle, user, target, move) + -- CheckDefrost: a burn-chance Fire move that lands thaws a frozen + -- target (regardless of the burn roll) + if move and move.type == "FIRE" and target.mon.status == "FRZ" then + target.mon.status = nil + return { ("Fire defrosted\n%s!"):format(displayName(target)) } + end + if battle.rng(0, 255) >= chance then return {} end + return inflictStatus(battle, target, status, { + moveType = move and move.type, + secondary = true, + }) + end +end + +local function statDownSide(stat) + return function(battle, user, target) + if target.substituteHP then return {} end + if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256) + -- StatModifierDownEffect's side-effect branch never runs MoveHitTest, + -- so the drop pierces MIST (only primary stat-lowering moves check it) + return changeStage(battle, target, stat, -1, false) + end +end + +local function flinchSide(chance) + return function(battle, user, target) + if target.substituteHP then return {} end + if battle.rng(0, 255) < chance then + target.flinched = true + end + return {} + end +end + +local function confuse(battle, target, pierceSub) + if target.confusedTurns or (target.substituteHP and not pierceSub) then + return { "But, it failed!" } + end + target.confusedTurns = battle.rng(2, 5) + return { ("%s\nbecame confused!"):format(displayName(target)) } +end + +-- --------------------------------------------------------------------- +-- primary (status-only move) handlers +-- --------------------------------------------------------------------- + +MoveEffects.primary = { + ATTACK_UP1_EFFECT = statUp("attack", 1), + ATTACK_UP2_EFFECT = statUp("attack", 2), + DEFENSE_UP1_EFFECT = statUp("defense", 1), + DEFENSE_UP2_EFFECT = statUp("defense", 2), + SPEED_UP2_EFFECT = statUp("speed", 2), + SPECIAL_UP1_EFFECT = statUp("special", 1), + SPECIAL_UP2_EFFECT = statUp("special", 2), + EVASION_UP1_EFFECT = statUp("evasion", 1), + + ATTACK_DOWN1_EFFECT = statDown("attack", 1), + DEFENSE_DOWN1_EFFECT = statDown("defense", 1), + DEFENSE_DOWN2_EFFECT = statDown("defense", 2), + SPEED_DOWN1_EFFECT = statDown("speed", 1), + ACCURACY_DOWN1_EFFECT = statDown("accuracy", 1), + + SLEEP_EFFECT = statusMove("SLP"), + POISON_EFFECT = statusMove("PSN"), + PARALYZE_EFFECT = statusMove("PAR"), + + CONFUSION_EFFECT = function(battle, user, target) + return confuse(battle, target) + end, + + LEECH_SEED_EFFECT = function(battle, user, target) + -- leech_seed.asm has no substitute check: seeding lands through one + if target.leechSeeded then + return { "But, it failed!" } + end + for _, t in ipairs(target.curTypes) do + if t == "GRASS" then return { "But, it failed!" } end + end + target.leechSeeded = true + return { ("%s\nwas seeded!"):format(displayName(target)) } + end, + + HEAL_EFFECT = function(battle, user, target, move) + local mon = user.mon + if move.id == "REST" then + if mon.hp == mon.stats.hp then return { "But, it failed!" } end + mon.hp = mon.stats.hp + mon.status = "SLP" + user.sleepTurns = 2 + user.toxicCounter = nil + return { ("%s\nstarted sleeping!"):format(displayName(user)) } + end + if mon.hp == mon.stats.hp then return { "But, it failed!" } end + mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2)) + return { ("%s\nregained health!"):format(displayName(user)) } + end, + + LIGHT_SCREEN_EFFECT = function(battle, user) + if user.lightScreen then return { "But, it failed!" } end + user.lightScreen = true + return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) } + end, + + REFLECT_EFFECT = function(battle, user) + if user.reflect then return { "But, it failed!" } end + user.reflect = true + return { ("%s\ngained armor!"):format(displayName(user)) } + end, + + MIST_EFFECT = function(battle, user) + if user.mist then return { "But, it failed!" } end + user.mist = true + -- _ShroudedInMistText (lowercase "mist") + return { ("%s's\nshrouded in mist!"):format(displayName(user)) } + end, + + FOCUS_ENERGY_EFFECT = function(battle, user) + if user.focusEnergy then return { "But, it failed!" } end + user.focusEnergy = true + return { ("%s's\ngetting pumped!"):format(displayName(user)) } + end, + + HAZE_EFFECT = function(battle, user, target) + for _, b in ipairs({ user, target }) do + b.stages = {} + b.confusedTurns = nil + b.leechSeeded = nil + b.toxicCounter = nil + b.reflect, b.lightScreen, b.mist, b.focusEnergy = nil, nil, nil, nil + -- haze.asm also zeroes both disabled-move slots and clears + -- USING_X_ACCURACY on both sides + b.disabledSlot, b.disabledTurns = nil, nil + b.xAccuracy = nil + -- haze.asm ResetStats copies each side's UNMODIFIED stats (8 bytes, + -- not HP) over its battle stats, which temporarily lifts the burn + -- Attack-halving and paralysis Speed-quartering on BOTH battlers + -- until the next stat recompute (a stage change or switch-in). + b.hazeStatReset = true + end + -- Gen 1 also removes the enemy's major status; if that cured sleep + -- or freeze, the target forfeits its move this turn (haze.asm + -- writes $ff/CANNOT_MOVE to its selected move) + if target.mon.status == "SLP" or target.mon.status == "FRZ" then + target.skipMove = true + end + target.mon.status = nil + return { "All STATUS changes\nare eliminated!" } + end, + + SUBSTITUTE_EFFECT = function(battle, user) + if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end + local cost = math.floor(user.mon.stats.hp / 4) + -- substitute.asm only fails on subtraction underflow (current HP + -- strictly below maxHP/4); at equality the substitute is built and + -- the user is left standing on exactly 0 HP (it faints only when + -- the engine next checks HP, not here) + if user.mon.hp < cost then + return { "Too weak to make\na SUBSTITUTE!" } + end + user.mon.hp = user.mon.hp - cost + user.substituteHP = cost + 1 + -- _SubstituteText + return { "It created a\nSUBSTITUTE!" } + end, + + CONVERSION_EFFECT = function(battle, user, target) + -- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE) + if target.invulnerable then + return { "But, it failed!" } + end + user.curTypes = { target.curTypes[1], target.curTypes[2] } + -- _ConvertedTypeText + return { ("Converted type to\n%s's!"):format(displayName(target)) } + end, + + -- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect + -- (effects.asm:1203-1273) runs mid-move -- hit test first, then the + -- player's copy menu pauses the message queue, which a table of + -- returned strings can't express. + + TRANSFORM_EFFECT = function(battle, user, target) + -- transform.asm:31-53 (AnimationTransformMon) morphs the user's + -- on-screen pic into the target species; the port swaps user.sprite + -- via the same getImage/monPalette path makeBattler uses so the + -- change is visible (the renderer draws battler.sprite directly). + user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer) + or user.sprite + user.curStats = { + hp = user.mon.stats.hp, -- HP is kept + attack = target.curStats.attack, defense = target.curStats.defense, + speed = target.curStats.speed, special = target.curStats.special, + } + user.curTypes = { target.curTypes[1], target.curTypes[2] } + -- transform.asm:130-132 copies the target's stat MODS into the user + -- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them; + -- deep copy so later stage changes on either mon stay independent + user.stages = {} + for stat, stage in pairs(target.stages) do user.stages[stat] = stage end + user.curMoves = {} + for _, mv in ipairs(target.curMoves) do + table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true }) + end + -- _TransformedText: the copied name prints bare (wNameBuffer) + return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) } + end, + + DISABLE_EFFECT = function(battle, user, target) + if target.disabledSlot then return { "But, it failed!" } end + local usable = {} + for i, mv in ipairs(target.curMoves) do + if mv.pp > 0 then table.insert(usable, i) end + end + if #usable == 0 then return { "But, it failed!" } end + local slot = usable[battle.rng(1, #usable)] + target.disabledSlot = slot + target.disabledTurns = battle.rng(1, 8) + local id = target.curMoves[slot].id + -- _MoveWasDisabledText: "X's / MOVE was / disabled!" + return { ("%s's\n%s was\ndisabled!"):format(displayName(target), + battle.data.moves[id].name) } + end, + + SPLASH_EFFECT = function() + return { "No effect!" } + end, +} + +-- --------------------------------------------------------------------- +-- secondary (after-damage) side effects +-- --------------------------------------------------------------------- + +MoveEffects.secondary = { + BURN_SIDE_EFFECT1 = statusSide("BRN", 26), + BURN_SIDE_EFFECT2 = statusSide("BRN", 77), + FREEZE_SIDE_EFFECT1 = statusSide("FRZ", 26), + PARALYZE_SIDE_EFFECT1 = statusSide("PAR", 26), + PARALYZE_SIDE_EFFECT2 = statusSide("PAR", 77), + POISON_SIDE_EFFECT1 = statusSide("PSN", 52), + POISON_SIDE_EFFECT2 = statusSide("PSN", 103), + FLINCH_SIDE_EFFECT1 = flinchSide(26), + FLINCH_SIDE_EFFECT2 = flinchSide(77), + ATTACK_DOWN_SIDE_EFFECT = statDownSide("attack"), + DEFENSE_DOWN_SIDE_EFFECT = statDownSide("defense"), + SPEED_DOWN_SIDE_EFFECT = statDownSide("speed"), + SPECIAL_DOWN_SIDE_EFFECT = statDownSide("special"), + CONFUSION_SIDE_EFFECT = function(battle, user, target) + if target.confusedTurns then return {} end + -- cp 10 percent (no +1): 25/256; ConfusionSideEffect never calls + -- CheckTargetSubstitute, so secondary confusion pierces a substitute + if battle.rng(0, 255) >= 25 then return {} end + return confuse(battle, target, true) + end, + TWINEEDLE_EFFECT = function(battle, user, target) + -- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1: + -- 20 percent + 1 (52/256) + if battle.rng(0, 255) >= 52 then return {} end + return inflictStatus(battle, target, "PSN", { secondary = true }) + end, +} + +-- effects fully handled inside BattleState's damage pipeline +MoveEffects.special = { + NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true, + ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true, + SUPER_FANG_EFFECT = true, OHKO_EFFECT = true, RECOIL_EFFECT = true, + DRAIN_HP_EFFECT = true, DREAM_EATER_EFFECT = true, CHARGE_EFFECT = true, + FLY_EFFECT = true, TRAPPING_EFFECT = true, THRASH_PETAL_DANCE_EFFECT = true, + JUMP_KICK_EFFECT = true, EXPLODE_EFFECT = true, HYPER_BEAM_EFFECT = true, + PAY_DAY_EFFECT = true, SWIFT_EFFECT = true, RAGE_EFFECT = true, + BIDE_EFFECT = true, SWITCH_AND_TELEPORT_EFFECT = true, + METRONOME_EFFECT = true, MIRROR_MOVE_EFFECT = true, + TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true, +} + +local warned = {} + +function MoveEffects.warnUnknown(effect) + if not warned[effect] then + warned[effect] = true + Logger.warn("move effect %s not implemented; treated as plain damage", effect) + end +end + +return MoveEffects diff --git a/src/battle/Status.lua b/src/battle/Status.lua new file mode 100644 index 00000000..f4410d60 --- /dev/null +++ b/src/battle/Status.lua @@ -0,0 +1,100 @@ +-- Per-turn status/volatile condition handling (Gen 1 semantics). + +local Status = {} + +-- Returns canMove, messages, selfHit (true -> hurt itself in confusion) +function Status.beforeMove(battler, rng) + local mon = battler.mon + -- Haze curing this mon's sleep/freeze forfeits its pending move for + -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected + -- move; ExecuteMove returns immediately without a message) + if battler.skipMove then + battler.skipMove = nil + return false, {} + end + if battler.flinched then + battler.flinched = false + return false, { battler.name .. "\nflinched!" } + end + if mon.status == "SLP" then + battler.sleepTurns = (battler.sleepTurns or 1) - 1 + if battler.sleepTurns <= 0 then + mon.status = nil + return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn + end + return false, { battler.name .. "\nis fast asleep!" } + end + if mon.status == "FRZ" then + return false, { battler.name .. "\nis frozen solid!" } + end + if battler.boundTurns and battler.boundTurns > 0 then + battler.boundTurns = battler.boundTurns - 1 + return false, { battler.name .. "\ncan't move!" } + end + local msgs = {} + if battler.disabledTurns then + battler.disabledTurns = battler.disabledTurns - 1 + if battler.disabledTurns <= 0 then + battler.disabledTurns, battler.disabledSlot = nil, nil + table.insert(msgs, battler.name .. "'s\ndisabled no more!") + end + end + if battler.confusedTurns then + battler.confusedTurns = battler.confusedTurns - 1 + if battler.confusedTurns <= 0 then + battler.confusedTurns = nil + table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!") + else + table.insert(msgs, battler.name .. "\nis confused!") + -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) + if rng(0, 255) < 128 then + return false, msgs, true -- hurt itself + end + end + end + -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) + if mon.status == "PAR" and rng(0, 255) < 63 then + table.insert(msgs, battler.name .. "'s\nfully paralyzed!") + return false, msgs + end + return true, msgs +end + +-- End-of-turn residual damage; opponent is needed for Leech Seed. +-- Returns messages. +function Status.residual(battler, opponent) + local msgs = {} + local mon = battler.mon + -- the Haze move-forfeit only covers the turn Haze was used; if this + -- mon had already moved, drop the flag before it leaks into next turn + battler.skipMove = nil + if mon.hp <= 0 then return msgs end + if mon.status == "PSN" or mon.status == "BRN" then + local base = math.max(1, math.floor(mon.stats.hp / 16)) + local dmg = base + if battler.toxicCounter then + dmg = base * battler.toxicCounter + battler.toxicCounter = battler.toxicCounter + 1 + end + mon.hp = math.max(0, mon.hp - dmg) + local what = mon.status == "PSN" and "poison" or "the burn" + table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what)) + end + if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then + -- the shared Toxic counter multiplies (and advances on) the seed + -- drain too -- the Gen 1 Leech Seed glitch + -- (HandlePoisonBurnLeechSeed_DecreaseOwnHP) + local dmg = math.max(1, math.floor(mon.stats.hp / 16)) + if battler.toxicCounter then + dmg = dmg * battler.toxicCounter + battler.toxicCounter = battler.toxicCounter + 1 + end + dmg = math.min(dmg, mon.hp) + mon.hp = mon.hp - dmg + opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) + table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name)) + end + return msgs +end + +return Status diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua new file mode 100644 index 00000000..f8c77487 --- /dev/null +++ b/src/battle/TrainerAI.lua @@ -0,0 +1,231 @@ +-- Trainer/wild move selection with the per-class "move choice +-- modification" layers from data/trainers/move_choices.asm +-- (engine/battle/trainer_ai.asm): +-- mod 1: heavily discourage zero-power status-ailment moves when the +-- player already has a status condition (they would fail) +-- mod 2: encourage stat-modifying (and neighbouring) move effects, +-- but only on the second move selection per enemy mon +-- (wAILayer2Encouragement == 1) +-- mod 3: encourage moves whose type is super effective against the +-- player (even non-damaging ones), discourage not-very- +-- effective/no-effect types when a "better move" is known +-- Faithful port of AIEnemyTrainerChooseMoves +-- (engine/battle/trainer_ai.asm:3-257): every candidate move starts at a +-- base score of 10; mod 1 adds 5, mod 2 subtracts 1, mod 3 subtracts 1 +-- (super-effective) or adds 1 (not-effective when a better move exists); +-- the MINIMUM-scored move is chosen, ties broken uniformly among the +-- tied minima (core.asm:2971-3002). A non-minimal move is never +-- selectable. Respects PP, Disable and Transform/Mimic move overrides. + +local TypeChart = require("src.battle.TypeChart") + +local TrainerAI = {} + +local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } +local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } + +-- Item use / switching per trainer class (engine/battle/trainer_ai.asm +-- via data/scripts/ai_classes.lua). Runs before move choice each enemy +-- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil. +-- battle.aiUses is initialized per enemy Pokémon (wAICount). +function TrainerAI.classAction(battle) + if battle.kind ~= "trainer" or not battle.trainer then return nil end + local class = require("data.scripts.ai_classes")[battle.trainer.id] + if not class then return nil end + if (battle.aiUses or 0) <= 0 then return nil end + local rng = battle.rng + local enemy = battle.enemy + local roll = rng(0, 255) + + -- Agatha's dedicated switch roll comes before her item roll + if class.switchChance and roll < class.switchChance then + return TrainerAI.switchAction(battle) + end + + if class.onStatus then + if enemy.mon.status then + return { special = "aiItem", item = class.item } + end + return nil + end + + if class.chance and roll >= class.chance then return nil end + + if class.switch then + return TrainerAI.switchAction(battle) + end + if class.hpBelow + and enemy.mon.hp >= math.floor(enemy.mon.stats.hp / class.hpBelow) then + if class.switchBelow + and enemy.mon.hp < math.floor(enemy.mon.stats.hp / class.switchBelow) then + return TrainerAI.switchAction(battle) + end + return nil + end + return { special = "aiItem", item = class.item } +end + +-- AISwitchIfEnoughMons (engine/battle/trainer_ai.asm:554-582): counts ALL +-- unfainted party mons including the active one and switches when that +-- total is >= 2 (cp 2 / jp nc) -- i.e. whenever at least ONE non-active +-- mon can still fight. Switch to the first (lowest-index) such backup, +-- matching EnemySendOutFirstMon (core.asm:1292-1341). +function TrainerAI.switchAction(battle) + local alive = {} + for i, mon in ipairs(battle.enemyParty or {}) do + if mon.hp > 0 and i ~= battle.enemyIndex then + table.insert(alive, i) + end + end + if #alive < 1 then return nil end + return { special = "aiSwitch", index = alive[1] } +end + +-- Apply an aiItem action to the enemy battler; returns messages. +function TrainerAI.useItem(battle, item) + local enemy = battle.enemy + local trainerName = battle.trainer.name + local itemName = battle.data.items[item] and battle.data.items[item].name or item + local msgs = { ("%s\nused %s!"):format(trainerName, itemName) } + if item == "FULL_HEAL" then + enemy.mon.status = nil + enemy.toxicCounter = nil + elseif item == "FULL_RESTORE" then + enemy.mon.hp = enemy.mon.stats.hp + enemy.mon.status = nil + enemy.toxicCounter = nil + elseif HEAL_AMOUNT[item] then + enemy.mon.hp = math.min(enemy.mon.stats.hp, enemy.mon.hp + HEAL_AMOUNT[item]) + elseif X_STAT[item] then + local stat = X_STAT[item] + enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) + table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper())) + elseif item == "GUARD_SPEC" then + enemy.mist = true + table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name)) + end + return msgs +end + +-- AIMoveChoiceModification1's StatusAilmentMoveEffects table: the two +-- sleep effects (EFFECT_01 is the unused one), poison and paralysis. +local STATUS_EFFECTS = { + EFFECT_01 = true, SLEEP_EFFECT = true, POISON_EFFECT = true, + PARALYZE_EFFECT = true, +} + +-- AIMoveChoiceModification2 encourages the two effect ranges +-- ATTACK_UP1_EFFECT..BIDE_EFFECT and ATTACK_UP2_EFFECT..POISON_EFFECT +-- (both exclusive of the upper bound): every stat modifier plus the +-- effects laid out between them in the constant list. +local ENCOURAGE_EFFECTS = { + -- $0A ATTACK_UP1_EFFECT .. $19 HAZE_EFFECT + ATTACK_UP1_EFFECT = true, DEFENSE_UP1_EFFECT = true, SPEED_UP1_EFFECT = true, + SPECIAL_UP1_EFFECT = true, ACCURACY_UP1_EFFECT = true, EVASION_UP1_EFFECT = true, + PAY_DAY_EFFECT = true, SWIFT_EFFECT = true, + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, SPEED_DOWN1_EFFECT = true, + SPECIAL_DOWN1_EFFECT = true, ACCURACY_DOWN1_EFFECT = true, EVASION_DOWN1_EFFECT = true, + CONVERSION_EFFECT = true, HAZE_EFFECT = true, + -- $32 ATTACK_UP2_EFFECT .. $41 REFLECT_EFFECT + ATTACK_UP2_EFFECT = true, DEFENSE_UP2_EFFECT = true, SPEED_UP2_EFFECT = true, + SPECIAL_UP2_EFFECT = true, ACCURACY_UP2_EFFECT = true, EVASION_UP2_EFFECT = true, + HEAL_EFFECT = true, TRANSFORM_EFFECT = true, + ATTACK_DOWN2_EFFECT = true, DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN2_EFFECT = true, + SPECIAL_DOWN2_EFFECT = true, ACCURACY_DOWN2_EFFECT = true, EVASION_DOWN2_EFFECT = true, + LIGHT_SCREEN_EFFECT = true, REFLECT_EFFECT = true, +} + +-- AIMoveChoiceModification3 .betterMoveFound: a "better move" is any +-- known move (PP and Disable ignored) with the Super Fang, fixed-damage +-- or Fly effect, or any damaging move of a different type than the move +-- being judged. +local BETTER_EFFECTS = { + SUPER_FANG_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true, FLY_EFFECT = true, +} + +local function hasBetterMove(battler, judged, battle) + for _, mv in ipairs(battler.curMoves) do + local d = battle.data.moves[mv.id] + if d then + if BETTER_EFFECTS[d.effect] then return true end + if d.type ~= judged.type and d.power > 0 then return true end + end + end + return false +end + +function TrainerAI.chooseMove(battler, rng, battle) + rng = rng or love.math.random + local usable = {} + for i, mv in ipairs(battler.curMoves) do + if mv.pp > 0 and battler.disabledSlot ~= i then + table.insert(usable, mv) + end + end + if #usable == 0 then + return { id = "STRUGGLE", pp = 1, struggle = true } + end + + -- wAILayer2Encouragement starts at 0 on each enemy send-out and gains + -- 1 per executed enemy move, so layer 2 (which needs it == 1) only + -- fires on the second move selection of each enemy mon. The port + -- counts selections instead of executions; they only diverge across + -- turns locked into a multi-turn move, which skip selection entirely. + local encourageTurn = (battler.aiLayer2 or 0) == 1 + battler.aiLayer2 = (battler.aiLayer2 or 0) + 1 + + local mods = battle and battle.enemyAIMods or nil + if not mods or #mods == 0 or not battle then + return usable[rng(1, #usable)] + end + + -- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every + -- usable move starts at a base score of 10; the class's modification + -- functions adjust it additively, then the MINIMUM-scored move is chosen + -- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a + -- fresh byte among the value-1 slots). A non-minimal move is never + -- selectable. + local target = battle.player + local scores = {} + for i, mv in ipairs(usable) do + local def = battle.data.moves[mv.id] + local s = 10 + for _, mod in ipairs(mods) do + if mod == 1 and def and target.mon.status + and def.power == 0 and STATUS_EFFECTS[def.effect] then + -- AIMoveChoiceModification1: `add $5` -- heavily discourage a + -- zero-power status move that would fail (player already statused) + s = s + 5 + elseif mod == 2 and def and encourageTurn + and ENCOURAGE_EFFECTS[def.effect] then + -- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage + s = s - 1 + elseif mod == 3 and def then + -- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads + -- the FIRST matching TypeEffects row for (move type vs either + -- defender type) -- no dual-type product -- and runs for + -- non-damaging moves too. The table holds no value-10 rows, so + -- >10 / <10 reproduces the oracle's compare against $10. + local row = TypeChart.rows(def.type, target.curTypes)[1] + if row and row > 10 then + s = s - 1 -- `dec [hl]`: encourage a super-effective move + elseif row and row < 10 and hasBetterMove(battler, def, battle) then + s = s + 1 -- `inc [hl]`: discourage when a better move is known + end + end + end + scores[i] = s + end + local best = math.huge + for _, s in ipairs(scores) do + if s < best then best = s end + end + local minima = {} + for i, s in ipairs(scores) do + if s == best then minima[#minima + 1] = usable[i] end + end + if #minima == 1 then return minima[1] end + return minima[rng(1, #minima)] +end + +return TrainerAI diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua new file mode 100644 index 00000000..d1aebf39 --- /dev/null +++ b/src/battle/TurnOrder.lua @@ -0,0 +1,48 @@ +-- Turn order, from engine/battle/core.asm MainInBattleLoop: compare +-- effective speed; ties are a coin flip. QUICK_ATTACK moves first and +-- COUNTER last (Gen 1 has only these two priority moves, checked by id). + +local Stats = require("src.pokemon.Stats") + +local TurnOrder = {} + +local function effectiveSpeed(battler) + local spd = Stats.applyStage(battler.curStats.speed, + battler.stages and battler.stages.speed or 0) + -- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed + if battler.badges and battler.badges.SOULBADGE then + spd = math.floor(spd * 9 / 8) + end + -- paralysis quarters speed; hazeStatReset suppresses it because Haze + -- (haze.asm ResetStats) copied the unmodified speed over the quartered + -- battle stat, lifting the penalty until the next stat recompute. + if battler.mon.status == "PAR" and not battler.hazeStatReset then + spd = math.max(1, math.floor(spd / 4)) + end + return spd +end + +local function priority(moveId) + if moveId == "QUICK_ATTACK" then return 1 end + if moveId == "COUNTER" then return -1 end + return 0 +end + +-- Returns true when battler a moves before battler b. invertTie flips +-- the coin-flip result only: lockstep link battles share one RNG +-- stream, so the guest inverts the tie roll to agree with the host on +-- who moves first. +function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie) + rng = rng or love.math.random + local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id) + if pa ~= pb then return pa > pb end + local sa, sb = effectiveSpeed(a), effectiveSpeed(b) + if sa ~= sb then return sa > sb end + local aFirst = rng(0, 1) == 0 + if invertTie then aFirst = not aFirst end + return aFirst +end + +TurnOrder.effectiveSpeed = effectiveSpeed + +return TurnOrder diff --git a/src/battle/TypeChart.lua b/src/battle/TypeChart.lua new file mode 100644 index 00000000..5899df01 --- /dev/null +++ b/src/battle/TypeChart.lua @@ -0,0 +1,55 @@ +-- Gen 1 type effectiveness from generated data (multipliers x10). +-- Like the original, each matchup row applies independently, so dual types +-- multiply (e.g. 20 * 5 -> neutral). + +local TypeChart = {} + +local index -- [atk][def] -> x10 multiplier +local matchups -- ROM-ordered TypeEffects rows + +function TypeChart.load(data) + index = {} + matchups = data.type_chart.matchups + for _, m in ipairs(matchups) do + index[m.attacker] = index[m.attacker] or {} + index[m.attacker][m.defender] = m.multiplier + end +end + +-- The x10 multipliers of every TypeEffects row that applies, in ROM +-- order. AdjustDamageForMoveType applies each row to the running +-- damage separately (one application per row even when both defender +-- types match it), so callers must floor after every row. +function TypeChart.rows(moveType, defenderTypes) + assert(matchups, "TypeChart.load not called") + local out = {} + for _, m in ipairs(matchups) do + if m.attacker == moveType then + for _, dt in ipairs(defenderTypes) do + if m.defender == dt then + out[#out + 1] = m.multiplier + break + end + end + end + end + return out +end + +-- Returns the combined x10 multiplier of moveType against a types list +-- (x100 for dual matchups is normalized back: each application is /10). +function TypeChart.effectiveness(moveType, defenderTypes) + assert(index, "TypeChart.load not called") + local mult = 10 + local row = index[moveType] + if not row then return mult end + for _, dt in ipairs(defenderTypes) do + local m = row[dt] + if m ~= nil then + mult = math.floor(mult * m / 10) + end + end + return mult +end + +return TypeChart diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua new file mode 100644 index 00000000..ae560d5e --- /dev/null +++ b/src/battle/rulesets/gen1_faithful.lua @@ -0,0 +1,16 @@ +-- Default ruleset: preserve Gen 1 behavior, including the famous quirks. + +return { + name = "gen1_faithful", + -- accuracy roll is rand(0..255) < floor(acc*255/100): a 100%-accurate + -- move still misses on a roll of 255 (the 1/256 miss) + oneIn256Miss = true, + -- critical hits use base speed (not current speed) and ignore stat stages + critUsesBaseSpeed = true, + critIgnoresStages = true, + -- damage random factor r in [217,255], damage = damage * r / 255 + randMin = 217, + randMax = 255, + -- Focus Energy famously QUARTERS the crit rate instead of x4 + focusEnergyBug = true, +} diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua new file mode 100644 index 00000000..9a8d747e --- /dev/null +++ b/src/battle/rulesets/modern_clean.lua @@ -0,0 +1,12 @@ +-- Optional ruleset that removes the most notorious Gen 1 quirks while +-- keeping the same formulas. Not the default. + +return { + name = "modern_clean", + oneIn256Miss = false, + critUsesBaseSpeed = true, + critIgnoresStages = false, + randMin = 217, + randMax = 255, + focusEnergyBug = false, +} diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua new file mode 100644 index 00000000..b6ea12a2 --- /dev/null +++ b/src/core/ChipAudio.lua @@ -0,0 +1,822 @@ +local bit = require("bit") + +local ChipAudio = {} + +local SAMPLE_RATE = 22050 +local TICKS_PER_SECOND = 15360 +local FRAME_TICKS = 256 +-- Desktop/mobile playback should tolerate render stalls such as window +-- resizing. The original queue was only about 0.37s deep; this gives the +-- queue roughly six seconds of headroom without changing the synthesized +-- Game Boy timing or pitch. +local MUSIC_BUFFER_SAMPLES = 4096 +local MUSIC_BUFFER_COUNT = 32 +local GB_CLOCK = 4194304 + +local PITCHES = { + 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, + 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, +} +local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 } +local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 } +local NOISE_DIVISORS = { + [0] = 8, [1] = 16, [2] = 32, [3] = 48, + [4] = 64, [5] = 80, [6] = 96, [7] = 112, +} + +local function snapTicks(ticks) + return math.floor((ticks * 735 + 256) / 512) +end + +local cachedProgramFile +local cachedBanks +local currentMusic + +local function loadBanks(data) + local audio = data.audio + if cachedProgramFile == audio.programFile and cachedBanks then + return cachedBanks + end + local raw, readError = love.filesystem.read(audio.programFile) + if not raw then error("could not read sound programs: " .. tostring(readError)) end + local banks = {} + for index, bank in ipairs(audio.bankOrder) do + local first = (index - 1) * 0x4000 + 1 + banks[bank] = raw:sub(first, first + 0x3FFF) + end + cachedProgramFile, cachedBanks = audio.programFile, banks + return banks +end + +local function romByte(banks, bank, address) + local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank)) + local value = bytes:byte(address - 0x4000 + 1) + if not value then + error(("audio read outside bank %02X:%04X"):format(bank, address)) + end + return value +end + +local function romWord(banks, bank, address) + return romByte(banks, bank, address) + + romByte(banks, bank, address + 1) * 0x100 +end + +local function headerChannels(banks, header) + local channels = {} + local address = header.address + local first = romByte(banks, header.bank, address) + local count = bit.rshift(bit.band(first, 0xF0), 6) + 1 + for _ = 1, count do + local descriptor = romByte(banks, header.bank, address) + channels[#channels + 1] = { + number = bit.band(descriptor, 0x0F) + 1, + address = romWord(banks, header.bank, address + 1), + } + address = address + 3 + end + return channels +end + +local function fadeValue(nibble) + if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end + return nibble +end + +local Channel = {} +Channel.__index = Channel + +function Channel.new(engine, spec, options) + options = options or {} + local hardware = (spec.number - 1) % 4 + 1 + local isSfxChannel = spec.number > 4 + return setmetatable({ + engine = engine, + bank = options.bank, + address = spec.address, + number = spec.number, + hardware = hardware, + wave = hardware == 3, + noise = hardware == 4, + sfx = isSfxChannel, + executeMusic = not isSfxChannel, + allowLoops = options.allowLoops ~= false, + frequencyOffset = options.frequencyOffset or 0, + frameTicks = options.frameTicks or FRAME_TICKS, + speed = 12, + volume = 12, + fade = 0, + duty = 0.5, + octave = 4, + waveInstrument = 0, + waveLevel = 1, + perfectPitch = false, + vibrato = nil, + pendingSlide = nil, + sweep = nil, + callStack = {}, + loopCounts = {}, + event = nil, + ended = false, + phase = 0, + noiseLfsr = 0x7FFF, + noiseClock = 0, + timeTicks = 0, + }, Channel) +end + +function Channel:byte() + local value = romByte(self.engine.banks, self.bank, self.address) + self.address = self.address + 1 + return value +end + +function Channel:word() + local value = romWord(self.engine.banks, self.bank, self.address) + self.address = self.address + 2 + return value +end + +function Channel:frequency(note, octave) + local signed = PITCHES[note + 1] - 0x10000 + local register = bit.band( + bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF) + if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end + return bit.band(register + self.frequencyOffset, 0x7FF) +end + +function Channel:durationTicks(length) + local tempo = self.sfx and self.frameTicks or self.engine.tempo + local speed = self.sfx and (self.executeMusic and self.speed or 1) + or self.speed + return length * speed * tempo +end + +function Channel:timedEvent(event, ticks) + local first = snapTicks(self.timeTicks) + self.timeTicks = self.timeTicks + ticks + event.duration = ticks / TICKS_PER_SECOND + event.samples = snapTicks(self.timeTicks) - first + event.sample = 0 + event.elapsed = 0 + return event +end + +function Channel:pan() + local mask = bit.lshift(1, self.hardware - 1) + return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0, + bit.band(self.engine.pan, mask) ~= 0 +end + +function Channel:tone(ticks, register, volume, fade) + if register >= 0x800 then + return self:timedEvent({ silence = true }, ticks) + end + local duration = ticks / TICKS_PER_SECOND + local panLeft, panRight = self:pan() + local slide + if self.pendingSlide then + slide = { + target = self.pendingSlide.target, + frames = math.max(1, duration * 60 - self.pendingSlide.length), + } + self.pendingSlide = nil + end + return self:timedEvent({ + register = register, + volume = volume == nil and self.volume or volume, + fade = fade == nil and self.fade or fade, + duty = self.duty, + wave = self.wave, + waveInstrument = self.waveInstrument, + waveLevel = self.waveLevel, + vibrato = slide and nil or self.vibrato, + slide = slide, + sweep = self.sfx and self.hardware == 1 and self.sweep or nil, + panLeft = panLeft, + panRight = panRight, + }, ticks) +end + +function Channel:noiseEvent(ticks, volume, fade, parameter) + local panLeft, panRight = self:pan() + return self:timedEvent({ + noise = true, + volume = volume or self.volume, + fade = fade or 0, + noiseParameter = parameter, + panLeft = panLeft, panRight = panRight, + }, ticks) +end + +function Channel:drumEvent(ticks, instrument) + local panLeft, panRight = self:pan() + return self:timedEvent({ + noise = true, + drum = self.engine:noiseInstrument(instrument), + panLeft = panLeft, + panRight = panRight, + }, ticks) +end + +function Channel:silenceEvent(ticks) + return self:timedEvent({ silence = true }, ticks) +end + +function Channel:nextEvent() + if self.ended then return nil end + for _ = 1, 100000 do + local commandAddress = self.address + local command = self:byte() + + if (self.executeMusic or not self.sfx) and command < 0xC0 then + local note = bit.rshift(command, 4) + local length = bit.band(command, 0x0F) + 1 + if self.noise then + local instrument = note + if command >= 0xB0 then instrument = self:byte() end + return self:drumEvent(self:durationTicks(length), instrument) + end + return self:tone(self:durationTicks(length), self:frequency(note)) + elseif command >= 0xC0 and command < 0xD0 then + local length = bit.band(command, 0x0F) + 1 + return self:silenceEvent(self:durationTicks(length)) + elseif command >= 0xD0 and command < 0xE0 then + self.speed = bit.band(command, 0x0F) + if not self.noise then + local packed = self:byte() + if self.wave then + self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] + self.waveInstrument = bit.band(packed, 0x0F) + else + self.volume = bit.rshift(packed, 4) + self.fade = fadeValue(bit.band(packed, 0x0F)) + end + end + elseif command >= 0xE0 and command <= 0xE7 then + self.octave = 8 - bit.band(command, 7) + elseif command == 0xE8 then + self.perfectPitch = not self.perfectPitch + elseif command == 0xE9 then + -- Unused command. + elseif command == 0xEA then + local delay, packed = self:byte(), self:byte() + local depth = bit.rshift(packed, 4) + if depth == 0 then + self.vibrato = nil + else + self.vibrato = { + delay = delay, + above = bit.rshift(depth, 1) + bit.band(depth, 1), + below = bit.rshift(depth, 1), + rate = bit.band(packed, 0x0F), + } + end + elseif command == 0xEB then + local length, packed = self:byte(), self:byte() + local octave = 8 - bit.rshift(packed, 4) + self.pendingSlide = { + length = length, + target = self:frequency(bit.band(packed, 0x0F), octave), + } + elseif command == 0xEC then + self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5 + elseif command == 0xED then + self.engine.tempo = self:byte() * 0x100 + self:byte() + elseif command == 0xEE then + self.engine.pan = self:byte() + elseif command == 0xEF or command == 0xF0 then + self:byte() + elseif command == 0xF8 then + self.executeMusic = not self.executeMusic + elseif command == 0xFC then + local packed = self:byte() + self.duty = { + DUTY[bit.band(bit.rshift(packed, 6), 3)], + DUTY[bit.band(bit.rshift(packed, 4), 3)], + DUTY[bit.band(bit.rshift(packed, 2), 3)], + DUTY[bit.band(packed, 3)], + } + elseif command == 0xFD then + self.callStack[#self.callStack + 1] = self.address + 2 + self.address = self:word() + elseif command == 0xFE then + local count, target = self:byte(), self:word() + if count == 0 then + if self.allowLoops then + self.address = target + else + self.ended = true + return nil + end + else + local remaining = self.loopCounts[commandAddress] + if remaining == nil then remaining = count end + remaining = remaining - 1 + if remaining > 0 then + self.loopCounts[commandAddress] = remaining + self.address = target + else + self.loopCounts[commandAddress] = nil + end + end + elseif command == 0xFF then + local returnAddress = table.remove(self.callStack) + if returnAddress then + self.address = returnAddress + else + self.ended = true + return nil + end + elseif self.sfx and command >= 0x20 and command < 0x30 then + local length = bit.band(command, 0x0F) + 1 + local packed = self:byte() + local volume = bit.rshift(packed, 4) + local fade = fadeValue(bit.band(packed, 0x0F)) + if self.noise then + local parameter = self:byte() + return self:noiseEvent( + self:durationTicks(length), volume, fade, parameter) + end + local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) + return self:tone(self:durationTicks(length), register, volume, fade) + elseif command == 0x10 then + local packed = self:byte() + self.sweep = { + pace = bit.band(bit.rshift(packed, 4), 7), + subtract = bit.band(packed, 8) ~= 0, + shift = bit.band(packed, 7), + } + else + self.ended = true + return nil + end + end + self.ended = true + return nil +end + +local function envelopeVolume(volume, fade, elapsed) + if fade == 0 then return volume end + local steps = math.floor(elapsed / (math.abs(fade) / 64)) + if fade > 0 then return math.max(0, volume - steps) end + return math.min(15, volume + steps) +end + +function Channel:resetNoise() + self.noiseLfsr = 0x7FFF + self.noiseClock = 0 +end + +function Channel:clockNoise(width7) + local feedback = bit.bxor( + bit.band(self.noiseLfsr, 1), + bit.band(bit.rshift(self.noiseLfsr, 1), 1)) + self.noiseLfsr = bit.bor( + bit.rshift(self.noiseLfsr, 1), + bit.lshift(feedback, 14)) + if width7 then + self.noiseLfsr = bit.bor( + bit.band(self.noiseLfsr, bit.bnot(0x40)), + bit.lshift(feedback, 6)) + end +end + +function Channel:sampleNoise(parameter) + parameter = parameter or 0 + local divisor = NOISE_DIVISORS[bit.band(parameter, 7)] + local shift = bit.rshift(parameter, 4) + local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 + if shift >= 14 then return output end + local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE + local width7 = bit.band(parameter, 8) ~= 0 + local remaining = cycles + local area = 0 + + while remaining > 0 do + local untilClock = 1 - self.noiseClock + local span = math.min(remaining, untilClock) + output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 + area = area + output * span + self.noiseClock = self.noiseClock + span + remaining = remaining - span + if self.noiseClock >= 1 - 1e-12 then + self.noiseClock = 0 + self:clockNoise(width7) + end + end + + return area / cycles +end + +local function sweepCalculation(register, sweep) + local delta = math.floor(register / (2 ^ sweep.shift)) + if sweep.subtract then return register - delta end + return register + delta +end + +local function sweptRegister(register, sweep, elapsed) + if not sweep or sweep.shift == 0 then return register end + local nextRegister = sweepCalculation(register, sweep) + if nextRegister > 0x7FF or nextRegister < 0 then return nil end + if sweep.pace == 0 then return register end + + local iterations = math.floor(elapsed * 128 / sweep.pace) + for _ = 1, iterations do + register = nextRegister + nextRegister = sweepCalculation(register, sweep) + if nextRegister > 0x7FF or nextRegister < 0 then return nil end + end + return register +end + +function Channel:sampleDrum(event, sampleIndex) + local index = event.drumSegmentIndex or 1 + local segment = event.drum[index] + while segment and sampleIndex >= segment.endSample do + index = index + 1 + segment = event.drum[index] + end + if not segment or sampleIndex < segment.startSample then return 0 end + if event.drumSegmentIndex ~= index then + event.drumSegmentIndex = index + self:resetNoise() + end + local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE + local volume = envelopeVolume(segment.volume, segment.fade, elapsed) + return self:sampleNoise(segment.parameter) * volume / 15 * 0.35 +end + +function Channel:sample() + while not self.ended + and (not self.event or self.event.sample >= self.event.samples) do + self.event = self:nextEvent() + self.phase = 0 + self:resetNoise() + end + local event = self.event + if not event then return 0 end + local sampleIndex = event.sample + event.elapsed = sampleIndex / SAMPLE_RATE + event.sample = sampleIndex + 1 + if event.silence then return 0 end + + if event.drum then return self:sampleDrum(event, sampleIndex) end + local volume = envelopeVolume( + event.volume or 0, event.fade or 0, event.elapsed) + if event.noise then + return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35 + end + + local register = event.register + local frame = math.floor(event.elapsed * 60) + if event.sweep then + register = sweptRegister(register, event.sweep, event.elapsed) + if not register then return 0 end + elseif event.slide then + local amount = math.min(1, frame / event.slide.frames) + register = register + (event.slide.target - register) * amount + elseif event.vibrato and frame >= event.vibrato.delay then + local vibrato = event.vibrato + local toggles = math.floor( + (frame - vibrato.delay + 1) / (vibrato.rate + 1)) + if toggles > 0 then + local low = bit.band(register, 0xFF) + local high = bit.band(register, 0x700) + if bit.band(toggles, 1) ~= 0 then + register = high + math.min(0xFF, low + vibrato.above) + else + register = high + math.max(0, low - vibrato.below) + end + end + end + local frequency = 131072 / (2048 - math.min(register, 2047)) + if event.wave then frequency = frequency * 0.5 end + local phase = self.phase + self.phase = (phase + frequency / SAMPLE_RATE) % 1 + if event.wave then + local wave = self.engine.waves[ + math.min(event.waveInstrument + 1, #self.engine.waves)] + local index = math.min(32, math.floor(phase * 32) + 1) + return wave[index] * event.waveLevel * 0.55 + end + local duty = event.duty + if type(duty) == "table" then + duty = duty[frame % 4 + 1] + end + return (phase < duty and 1 or -1) * volume / 15 * 0.5 +end + +local Engine = {} +Engine.__index = Engine + +function Engine:noiseInstrument(number) + local cached = self.noiseInstruments[number] + if cached then return cached end + + local header = self.noiseHeaders[tostring(number)] + local segments = {} + if header then + local spec = headerChannels(self.banks, header)[1] + local address = spec and spec.address + local ticks = 0 + for _ = 1, 64 do + local command = romByte(self.banks, header.bank, address) + address = address + 1 + if command == 0xFF then break end + if command < 0x20 or command >= 0x30 then + error(("unsupported drum command %02X at %02X:%04X") + :format(command, header.bank, address - 1)) + end + local packed = romByte(self.banks, header.bank, address) + local parameter = romByte(self.banks, header.bank, address + 1) + address = address + 2 + local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS + segments[#segments + 1] = { + startSample = snapTicks(ticks), + endSample = snapTicks(ticks + duration), + volume = bit.rshift(packed, 4), + fade = fadeValue(bit.band(packed, 0x0F)), + parameter = parameter, + } + ticks = ticks + duration + end + end + + self.noiseInstruments[number] = segments + return segments +end + +local function readWaves(banks, audio, engineNumber) + local spec = audio.waveBanks[tostring(engineNumber)] + local waves = {} + for wave = 0, 4 do + local values = {} + for byteIndex = 0, 15 do + local packed = romByte( + banks, spec.bank, spec.address + wave * 16 + byteIndex) + values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5 + values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5 + end + waves[#waves + 1] = values + end + local values = {} + for byteIndex = 0, 15 do + local packed = romByte( + banks, spec.bank, spec.address + 5 * 16 + byteIndex) + values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5 + values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5 + end + for _ = 1, 4 do waves[#waves + 1] = values end + return waves +end + +function Engine.new(data, header, options) + options = options or {} + local banks = loadBanks(data) + local engine = setmetatable({ + banks = banks, + tempo = 0x100, + pan = 0xFF, + waves = readWaves(banks, data.audio, header.engine), + noiseHeaders = data.audio.noiseHeaders + and data.audio.noiseHeaders[tostring(header.engine)] or {}, + noiseInstruments = {}, + channels = {}, + }, Engine) + for _, spec in ipairs(headerChannels(banks, header)) do + local frameTicks = options.frameTicks + local hardware = (spec.number - 1) % 4 + 1 + if hardware == 4 then + frameTicks = FRAME_TICKS + elseif options.cryLength then + frameTicks = 0x80 + options.cryLength + end + engine.channels[#engine.channels + 1] = Channel.new(engine, spec, { + bank = header.bank, + sfx = options.sfx, + allowLoops = options.allowLoops, + frequencyOffset = options.frequencyOffset, + frameTicks = frameTicks, + }) + end + return engine +end + +function Engine:finished() + for _, channel in ipairs(self.channels) do + if not channel.ended or channel.event then return false end + end + return true +end + +function Engine:sample() + local value = 0 + for _, channel in ipairs(self.channels) do value = value + channel:sample() end + return math.max(-1, math.min(1, value * 0.5)) +end + +function Engine:sampleStereo() + local left, right = 0, 0 + for _, channel in ipairs(self.channels) do + local value = channel:sample() + local event = channel.event + if not event or event.panLeft ~= false then left = left + value end + if not event or event.panRight ~= false then right = right + value end + end + return math.max(-1, math.min(1, left * 0.5)), + math.max(-1, math.min(1, right * 0.5)) +end + +function Engine:sampleChannel(number) + local selected = 0 + for _, channel in ipairs(self.channels) do + local value = channel:sample() + if channel.number == number then selected = value end + end + return math.max(-1, math.min(1, selected * 0.5)) +end + +local function soundData(engine, samples, channels) + local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) + for index = 0, samples - 1 do + if channels == 2 then + local left, right = engine:sampleStereo() + result:setSample(index, 1, left) + result:setSample(index, 2, right) + else + result:setSample(index, engine:sample()) + end + end + return result +end + +local function fillMusic() + local music = currentMusic + if not music or music.engine:finished() then return end + local free = music.source:getFreeBufferCount() + while free > 0 and not music.engine:finished() do + music.source:queue(soundData( + music.engine, MUSIC_BUFFER_SAMPLES, 2)) + free = free - 1 + end +end + +function ChipAudio.playMusic(data, header, allowLoops) + ChipAudio.stopMusic() + local ok, source = pcall( + love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT) + if not ok then return nil, source end + currentMusic = { + source = source, + engine = Engine.new(data, header, { allowLoops = allowLoops }), + } + fillMusic() + source:play() + return source +end + +-- Recover from an audio queue underrun caused by a long render stall. This +-- is called after Music has handled intentional fanfare pauses, so it never +-- fights the normal pause/resume behavior. +function ChipAudio.ensureMusicPlaying() + local music = currentMusic + if not music or music.engine:finished() then return end + local ok, playing = pcall(music.source.isPlaying, music.source) + if ok and not playing then + fillMusic() + pcall(music.source.play, music.source) + end +end + +function ChipAudio.update() + fillMusic() +end + +function ChipAudio.stopMusic() + if currentMusic and currentMusic.source then + pcall(currentMusic.source.stop, currentMusic.source) + end + currentMusic = nil +end + +local function renderEffect(data, header, options) + if not header then return nil end + options = options or {} + options.sfx = true + options.allowLoops = false + local engine = Engine.new(data, header, options) + local maximum = SAMPLE_RATE * 5 + local values = {} + local count = 0 + while count < maximum and not engine:finished() do + count = count + 1 + values[count] = engine:sample() + end + if count < math.floor(SAMPLE_RATE / 100) then return nil end + local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1) + for index = 1, count do result:setSample(index - 1, values[index]) end + return love.audio.newSource(result, "static") +end + +function ChipAudio._renderMusicForTest(data, header, seconds) + local engine = Engine.new(data, header, { allowLoops = true }) + return soundData(engine, math.floor(seconds * SAMPLE_RATE), 2) +end + +function ChipAudio._renderMusicChannelForTest(data, header, seconds, number) + local engine = Engine.new(data, header, { allowLoops = true }) + local samples = math.floor(seconds * SAMPLE_RATE) + local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) + for index = 0, samples - 1 do + result:setSample(index, engine:sampleChannel(number)) + end + return result +end + +function ChipAudio._traceFirstMusicSampleForTest(data, header) + local engine = Engine.new(data, header, { allowLoops = true }) + local result = {} + for _, channel in ipairs(engine.channels) do + local value = channel:sample() + local event = channel.event or {} + result[#result + 1] = { + number = channel.number, + value = value, + register = event.register, + duration = event.duration, + volume = event.volume, + duty = event.duty, + wave = event.wave, + waveInstrument = event.waveInstrument, + drumSegments = event.drum and #event.drum or nil, + noiseParameter = event.noiseParameter, + sweep = event.sweep, + } + end + return result +end + +function ChipAudio._traceFirstSfxSampleForTest(data, header) + local engine = Engine.new(data, header, { + sfx = true, + allowLoops = false, + }) + local result = {} + for _, channel in ipairs(engine.channels) do + local value = channel:sample() + local event = channel.event or {} + result[#result + 1] = { + number = channel.number, + value = value, + register = event.register, + duration = event.duration, + volume = event.volume, + fade = event.fade, + noiseParameter = event.noiseParameter, + sweep = event.sweep, + } + end + return result +end + +function ChipAudio._renderSfxForTest(data, header, seconds) + local engine = Engine.new(data, header, { + sfx = true, + allowLoops = false, + }) + return soundData(engine, math.floor(seconds * SAMPLE_RATE), 1) +end + +function ChipAudio.newSfx(data, name, pitch, tempo, header) + header = header or data.audio.sfx[name] + return renderEffect(data, header, { + frequencyOffset = pitch or 0, + frameTicks = 0x80 + (tempo or 0x80), + }) +end + +function ChipAudio.newCry(data, species) + local cry = data.audio.cries[species] + if not cry then return nil end + return renderEffect(data, cry.header, { + frequencyOffset = cry.pitch, + cryLength = cry.length, + }) +end + +function ChipAudio.newLowHealthAlarm() + local samples = math.floor(SAMPLE_RATE * 62 / 60) + local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) + local phase = 0 + for index = 0, samples - 1 do + local frame = math.floor(index * 60 / SAMPLE_RATE) % 31 + local register = frame < 11 and 0x750 or 0x6EE + local frequency = 131072 / (2048 - register) + phase = (phase + frequency / SAMPLE_RATE) % 1 + data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25) + end + return love.audio.newSource(data, "static") +end + +return ChipAudio diff --git a/src/core/Data.lua b/src/core/Data.lua new file mode 100644 index 00000000..0eb88e03 --- /dev/null +++ b/src/core/Data.lua @@ -0,0 +1,64 @@ +-- Loads generated data from either the private first-boot cache or the +-- optional source-tree developer build. + +local Logger = require("src.core.Logger") + +local Data = {} + +local MODULES = { + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", +} + +-- Optional for compatibility with developer and stale caches. +local OPTIONAL = { "audio", "palettes", "icons" } + +function Data:load() + for _, name in ipairs(MODULES) do + local ok, mod = pcall(require, "data.generated." .. name) + if not ok then + error(("missing generated data module 'data/generated/%s.lua'.\n" .. + "Import the ROM again or rebuild developer data.\n(%s)") + :format(name, mod)) + end + self[name] = mod + end + for _, name in ipairs(OPTIONAL) do + local ok, mod = pcall(require, "data.generated." .. name) + self[name] = ok and mod or nil + if not ok then + Logger.warn("optional data module '%s' missing (feature disabled)", name) + end + end + Logger.info("generated data loaded (%d maps, %d species, %d moves)", + (function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(), + (function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(), + (function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)()) +end + +-- Resolve a TEXT_* constant on a map to a plain string (or nil if the text +-- needs a hand-ported script; see data/scripts/). +function Data:resolveText(mapLabel, textConst) + local entry = self:textEntry(mapLabel, textConst) + if not entry then return nil end + if entry.text then + local s = self.text[entry.text] + if s then return s, entry.asm end + end + return nil, entry.asm +end + +-- The raw text-pointer entry (carries mart/nurse/pc markers and the label). +function Data:textEntry(mapLabel, textConst) + local perMap = self.text_pointers[mapLabel] + return perMap and perMap[textConst] or nil +end + +-- Trainer sight/dialogue header for a map object (or nil). +function Data:trainerHeader(mapLabel, objIndex) + local perMap = self.trainer_headers[mapLabel] + return perMap and perMap[objIndex] or nil +end + +return Data diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua new file mode 100644 index 00000000..e31ea8f6 --- /dev/null +++ b/src/core/FixedStep.lua @@ -0,0 +1,23 @@ +-- Fixed-step update loop at the Game Boy's ~60Hz. Game logic advances in +-- whole steps regardless of the display refresh rate, which keeps movement, +-- text speed and battle timing deterministic. + +local FixedStep = {} + +FixedStep.STEP = 1 / 60 +local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall + +function FixedStep:init(callback) + self.accum = 0 + self.callback = callback +end + +function FixedStep:update(dt) + self.accum = math.min(self.accum + dt, MAX_ACCUM) + while self.accum >= self.STEP do + self.accum = self.accum - self.STEP + self.callback(self.STEP) + end +end + +return FixedStep diff --git a/src/core/Game.lua b/src/core/Game.lua new file mode 100644 index 00000000..2ddf38b1 --- /dev/null +++ b/src/core/Game.lua @@ -0,0 +1,298 @@ +-- Central game object: owns the data, renderer, input, state stack, world +-- and save state. Everything else reaches shared services through here. + +local Data = require("src.core.Data") +local FixedStep = require("src.core.FixedStep") +local Input = require("src.core.Input") +local Logger = require("src.core.Logger") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TouchInput = require("src.core.TouchInput") +local ModLoader = require("src.mods.Loader") + +local Game = {} + +function Game:load() + self.data = Data + Data:load() + + -- Mods are a native engine subsystem. They load after the verified ROM + -- data exists, so mods can register or override the same definitions that + -- the rest of the game consumes. A broken mod is reported and skipped by + -- the loader without preventing the base game from booting. + self.mods = ModLoader.new() + self.mods:load(Data) + self.modStatus = self.mods:status() + + self.input = Input + Input:init() + + self.touchInput = TouchInput + TouchInput:init() + + self.renderer = Renderer + Renderer:init() + + require("src.render.Font").load(Data) + + self.stack = StateStack + StateStack:init() + + self.save = SaveData.newGame() + -- apply the persisted audio + display options before anything plays + self:applyOptions(self.save.options) + + FixedStep:init(function(step) self:step(step) end) + self.fixedStep = FixedStep + + local OverworldState = require("src.world.OverworldController") + self.overworld = OverworldState + + -- boot into the title screen (engine/movie/title.asm); NEW GAME runs + -- the Oak speech + naming, CONTINUE restores the save. The headless + -- autopilot skips straight into the overworld. + if os.getenv("POKEPORT_AUTOPILOT") then + StateStack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, self.save.player.facing) + else + local titleState = self:makeTitleState() + -- the copyright splash + Nidorino-vs-Gengar attract movie plays + -- before the title (engine/movie/splash.asm + intro.asm) + local IntroMovie = require("src.ui.IntroMovie") + StateStack:push(IntroMovie.new(self, function() + StateStack:push(titleState) + end)) + end + + Logger.info("game loaded") +end + +-- the title screen with its NEW GAME / CONTINUE wiring; used at boot +-- and by the START-menu QUIT confirmation +function Game:makeTitleState() + local TitleState = require("src.ui.TitleState") + local OverworldState = require("src.world.OverworldController") + return TitleState.new(self, { + onNewGame = function() + while self.stack:top() do self.stack:pop() end + -- New Game keeps the standalone options.lua preferences + self.save = SaveData.newGame() + self:applyOptions(self.save.options) + self.stack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, + self.save.player.facing) + local OakSpeech = require("src.ui.OakSpeech") + self.stack:push(OakSpeech.new(self, function() end)) + end, + onContinue = function() + local loaded = SaveData.load() + if loaded then + self:restoreSave(loaded) + end + end, + }) +end + +-- QUIT from the START menu: back to the title like a power-cycle, +-- unsaved progress discarded. TitleState:enter restarts the title +-- theme; stop() keeps the map song from bleeding over in the meantime. +function Game:returnToTitle() + require("src.core.Music").stop() + while self.stack:top() do self.stack:pop() end + self.stack:push(self:makeTitleState()) +end + +function Game:step(dt) + self.input:step() + -- serviced unconditionally: a link battle's ENet transport must not + -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily + -- on top of BattleState (see LinkBattle.new) + if self.linkNet and not self.linkNet.closed then + self.linkNet:update() + end + self.stack:update(dt) + -- play time for the trainer card / save screen + self.save.playTime = (self.save.playTime or 0) + dt + require("src.core.Music").update(Data) +end + +function Game:update(dt) + -- Touch timers / prior-frame auto-releases before the fixed step so + -- deferred A and edge pulses land in Input's press queue for this step. + TouchInput:update(dt) + FixedStep:update(dt) + -- Overworld tilt toggle tween: presentational, so it runs on the real + -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. + require("src.render.Tilt").update(dt) +end + +function Game:draw() + -- the UI canvas clears transparent when the overworld's world pass + -- shows through beneath it; opaque full-screen states get the classic + -- white clear + local base = self.stack:visibleBase() + local worldBelow = self.stack.states[base] == self.overworld + Renderer:beginFrame(worldBelow) + self.stack:draw() + -- SGB colorization: the topmost state that knows its palette owns the + -- screen (overlays like text boxes inherit from what's beneath them); + -- the overworld's world pass colors each visible map area separately + local zones, worldZones + for i = #self.stack.states, 1, -1 do + local s = self.stack.states[i] + if s.sgbPalettes then + zones = s:sgbPalettes(self) + break + end + end + if worldBelow and self.overworld.sgbWorldZones then + worldZones = self.overworld:sgbWorldZones() + end + Renderer:endFrame(zones, worldZones) +end + +-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out +function Game:zoomStep(delta) + local Zoom = require("src.render.Zoom") + if not Zoom.gateOK(self.stack:top(), self.overworld) then return end + Zoom.step(delta, Renderer:fitScale()) +end + +function Game:wheelmoved(_, dy) + if dy > 0 then + self:zoomStep(1) + elseif dy < 0 then + self:zoomStep(-1) + end +end + +function Game:keypressed(key) + if self.stack and self.stack:top() and self.stack:top().onKeyPressed then + self.stack:top():onKeyPressed(key) + return + end + if key == "f10" then + local ManagerState = require("src.mods.ManagerState") + self.stack:push(ManagerState.new(self)) + return + end + if key == "f1" then + self:writeSave() + return + elseif key == "f2" then + local loaded = SaveData.load() + if loaded then self:restoreSave(loaded) end + return + elseif key == "-" then + self:zoomStep(-1) + return + elseif key == "=" then + self:zoomStep(1) + return + elseif key == "2" then + -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on + local PaletteFX = require("src.render.PaletteFX") + self.save.options.colors = PaletteFX.cycleMode() + self:writeOptions() + return + elseif key == "3" then + -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only + local Tilt = require("src.render.Tilt") + if Tilt.gateOK(self.stack:top(), self.overworld) then + self.save.options.tilt = Tilt.cycle() + self:writeOptions() + end + return + elseif key == "5" then + -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on + local GBCFX = require("src.render.GBCFX") + self.save.options.gbcfx = GBCFX.cycle() + self:writeOptions() + return + end + Input:keypressed(key) +end + +-- Mod enablement is stored with persistent options. Restarting the actual +-- LÖVE process ensures scripts, registries, and assets are all rebuilt from +-- the newly selected mod state. +function Game:restartWithMods() + if love.event and love.event.quit then + love.event.quit("restart") + end +end + +function Game:keyreleased(key) + Input:keyreleased(key) +end + +function Game:gamepadpressed(joystick, button) + Input:gamepadpressed(joystick, button) +end + +function Game:gamepadreleased(joystick, button) + Input:gamepadreleased(joystick, button) +end + +function Game:gamepadaxis(joystick, axis, value) + Input:gamepadaxis(joystick, axis, value) +end + +function Game:touchpressed(id, x, y) + TouchInput:touchpressed(id, x, y) +end + +function Game:touchmoved(id, x, y) + TouchInput:touchmoved(id, x, y) +end + +function Game:touchreleased(id, x, y) + TouchInput:touchreleased(id, x, y) +end + +-- Capture the live world state into the save table and persist it. +-- Options are flushed to options.lua as part of SaveData.save. +function Game:writeSave() + if self.overworld and self.overworld.captureSave then + self.overworld:captureSave(self.save) + end + SaveData.save(self.save) +end + +-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings +-- across New Game without touching the progress save. +function Game:writeOptions() + if not (self.save and self.save.options) then return end + SaveData.saveOptions(self.save.options) +end + +-- Push the live options table into audio + display subsystems. +function Game:applyOptions(opts) + opts = opts or (self.save and self.save.options) or {} + local Music = require("src.core.Music") + local Sound = require("src.core.Sound") + if Music.applyOptions then Music.applyOptions(opts) end + if Sound.applyOptions then Sound.applyOptions(opts) end + require("src.render.PaletteFX").applyOptions(opts) + require("src.render.Tilt").applyOptions(opts) + require("src.render.GBCFX").applyOptions(opts) +end + +function Game:restoreSave(loaded) + self.save = loaded + -- SaveData.load already attached the standalone options.lua table + self:applyOptions(loaded.options) + -- saves from before OT/ID stamping: backfill with the player's + local stamp = require("src.battle.BattleState").stampOT + for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end + for _, box in ipairs(loaded.boxes or {}) do + for _, mon in ipairs(box) do stamp(loaded, mon) end + end + -- rebuild the state stack from the save + while self.stack:top() do self.stack:pop() end + self.stack:push(self.overworld, loaded.player.map, + loaded.player.x, loaded.player.y, loaded.player.facing) +end + +return Game diff --git a/src/core/Input.lua b/src/core/Input.lua new file mode 100644 index 00000000..5bd19ee4 --- /dev/null +++ b/src/core/Input.lua @@ -0,0 +1,123 @@ +-- Input abstraction: maps keyboard to Game Boy buttons. +-- `down` = held this frame; `pressed` = edge, consumed per fixed step. + +local Input = {} + +local BINDINGS = { + up = "up", w = "up", + down = "down", s = "down", + left = "left", a = "left", + right = "right", d = "right", + z = "a", ["return"] = "a", space = "a", + x = "b", backspace = "b", + ["kpenter"] = "start", escape = "start", + rshift = "select", +} + +-- keys that map to "start" but also to "a" would conflict; keep Enter = a, +-- Escape = start for desktop friendliness. + +-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent +-- across Xbox/PlayStation/generic controllers on desktop and mobile. +local GAMEPAD_BINDINGS = { + dpup = "up", dpdown = "down", dpleft = "left", dpright = "right", + a = "a", b = "b", + start = "start", back = "select", +} + +-- left-stick deadzones: press past STICK_ON, release once back under +-- STICK_OFF. The gap (hysteresis) stops the direction from flickering +-- while the stick sits near the threshold. +local STICK_ON = 0.5 +local STICK_OFF = 0.3 + +function Input:init() + self.state = {} + self.pressQueue = {} + self.pressed = {} + self.stickAxis = { x = 0, y = 0 } + self.stickDir = nil +end + +function Input:keypressed(key) + local btn = BINDINGS[key] + if btn then + table.insert(self.pressQueue, btn) + end +end + +function Input:keyreleased(key) + local btn = BINDINGS[key] + if btn then + self.state[btn] = false + end +end + +-- Called once per fixed step: promote queued presses to this step's edges. +function Input:step() + self.pressed = {} + for _, btn in ipairs(self.pressQueue) do + self.pressed[btn] = true + self.state[btn] = true + end + self.pressQueue = {} +end + +function Input:gamepadpressed(joystick, button) + local btn = GAMEPAD_BINDINGS[button] + if btn then + table.insert(self.pressQueue, btn) + end +end + +function Input:gamepadreleased(joystick, button) + local btn = GAMEPAD_BINDINGS[button] + if btn then + self.state[btn] = false + end +end + +-- left stick treated as a continuous held direction, same 4-way rule as +-- the touch swipe d-pad: whichever axis has the larger magnitude wins. +function Input:gamepadaxis(joystick, axis, value) + if axis == "leftx" then + self.stickAxis.x = value + elseif axis == "lefty" then + self.stickAxis.y = value + else + return + end + + local x, y = self.stickAxis.x, self.stickAxis.y + local ax, ay = math.abs(x), math.abs(y) + local newDir = self.stickDir + if ax > STICK_ON or ay > STICK_ON then + if ax >= ay then + newDir = x > 0 and "right" or "left" + else + newDir = y > 0 and "down" or "up" + end + elseif ax < STICK_OFF and ay < STICK_OFF then + newDir = nil + end + + if newDir ~= self.stickDir then + if self.stickDir then + self.state[self.stickDir] = false + end + if newDir then + table.insert(self.pressQueue, newDir) + end + self.stickDir = newDir + end +end + +function Input:isDown(btn) + return self.state[btn] or false +end + +function Input:wasPressed(btn) + return self.pressed[btn] or false +end + +return Input diff --git a/src/core/Logger.lua b/src/core/Logger.lua new file mode 100644 index 00000000..896f898b --- /dev/null +++ b/src/core/Logger.lua @@ -0,0 +1,19 @@ +-- Minimal logger; warnings are collected so debug overlays can show them. + +local Logger = { history = {} } + +local function emit(level, fmt, ...) + local msg = select("#", ...) > 0 and string.format(fmt, ...) or fmt + local line = string.format("[%s] %s", level, msg) + print(line) + table.insert(Logger.history, line) + if #Logger.history > 200 then + table.remove(Logger.history, 1) + end +end + +function Logger.info(fmt, ...) emit("info", fmt, ...) end +function Logger.warn(fmt, ...) emit("warn", fmt, ...) end +function Logger.error(fmt, ...) emit("error", fmt, ...) end + +return Logger diff --git a/src/core/Music.lua b/src/core/Music.lua new file mode 100644 index 00000000..ab220031 --- /dev/null +++ b/src/core/Music.lua @@ -0,0 +1,362 @@ +-- Music playback supports compact ROM channel programs synthesized live by +-- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs +-- chain def.file into def.loopFile in Music.update(). +-- Map themes switch on map change; battles override with the battle +-- theme and restore afterwards; riding the bike overrides outdoor map +-- themes with Music_BikeRiding until dismount. + +local Logger = require("src.core.Logger") + +local Music = {} + +local VOLUME = 0.7 + +-- port additions driven by OptionsMenu / save.options: musicVol scales +-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter +-- low-passes the song. Each filter step keeps 40% of the previous +-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter +-- applied twice/three times over. +local volumeScale = 1 +local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } +local filterLevel = 0 + +local function applyVolume(src) + if src then pcall(src.setVolume, src, VOLUME * volumeScale) end +end + +-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered +-- audio where it's missing (and under the headless stub) +local function applyFilter(src) + if not src then return end + if filterLevel > 0 then + pcall(src.setFilter, src, { type = "lowpass", volume = 1, + highgain = FILTER_HIGHGAIN[filterLevel] }) + else + pcall(src.setFilter, src) + end +end + +local state = { + enabled = true, + current = nil, -- song label + source = nil, -- currently playing source + loopSource = nil, -- pre-loaded loop body waiting for the intro to end + mapSong = nil, -- song to restore after a battle + onBike = false, -- bike theme overrides outdoor map themes + surfing = false, -- surf theme likewise (home/audio.asm MUSIC_SURFING) + pendingRestore = nil, + fanfare = nil, -- fanfare SFX source; the song pauses while it plays + fanfareResume = false, -- start/resume state.source when the fanfare ends + fade = nil, -- active volume-ramp fade-out (see Music.fadeOut) +} + +-- Is a fanfare SFX (Sound.lua's FANFARES) still sounding? +local function fanfareActive() + local src = state.fanfare + if not src then return false end + local ok, playing = pcall(src.isPlaying, src) + if ok and playing then return true end + state.fanfare = nil + return false +end + +-- Called by Sound.play when a fanfare starts: fanfares own the music +-- channels on the Game Boy, so the current song halts and resumes when +-- the jingle ends (see update()). +function Music.duckForFanfare(src) + if not state.enabled or not src then return end + state.fanfare = src + if state.source then + local ok, playing = pcall(state.source.isPlaying, state.source) + if ok and playing then + pcall(state.source.pause, state.source) + state.fanfareResume = true + end + end +end + +-- Overworld themes where the bike can be ridden (outdoor maps plus the +-- caves/dungeons where gen-1 allows cycling). Indoor themes such as +-- Pokecenter/Gym/SilphCo never get replaced by the bike theme. +local OUTDOOR = { + Music_PalletTown = true, + Music_Cities1 = true, + Music_Cities2 = true, + Music_Celadon = true, + Music_Cinnabar = true, + Music_Vermilion = true, + Music_Lavender = true, + Music_Routes1 = true, + Music_Routes2 = true, + Music_Routes3 = true, + Music_Routes4 = true, + Music_IndigoPlateau = true, + Music_SafariZone = true, + Music_Dungeon1 = true, + Music_Dungeon2 = true, + Music_Dungeon3 = true, +} + +local function songDef(data, song) + return data and data.audio and data.audio.songs and data.audio.songs[song] +end + +local function stopSource(src) + if src then pcall(src.stop, src) end +end + +local function newSource(file) + local ok, src = pcall(love.audio.newSource, file, "stream") + if ok and src then return src end + Logger.warn("music: cannot load %s", tostring(file)) + return nil +end + +function Music.play(data, song, loop) + if not state.enabled or not song or song == state.current then return end + if not love.audio then -- headless test stub + state.enabled = false + return + end + local def = songDef(data, song) + local runtime = data and data.audio and data.audio.runtime + if not def or (not runtime and not def.file) then return end + stopSource(state.source) + stopSource(state.loopSource) + if runtime then require("src.core.ChipAudio").stopMusic() end + state.source, state.loopSource, state.fade = nil, nil, nil + local wantLoop = loop ~= false + local src + if runtime then + local ok, generated = pcall( + require("src.core.ChipAudio").playMusic, data, def, wantLoop) + if ok then src = generated end + else + src = newSource(def.file) + end + if not src then + state.enabled = false + state.current = nil + return + end + if not runtime and def.loopFile then + -- intro file plays once, then update() chains to the loop body + -- (for one-shot jingles the body plays once and doesn't repeat) + pcall(src.setLooping, src, false) + local loopSrc = newSource(def.loopFile) + if loopSrc then + pcall(loopSrc.setLooping, loopSrc, wantLoop) + applyVolume(loopSrc) + applyFilter(loopSrc) + state.loopSource = loopSrc + else + pcall(src.setLooping, src, wantLoop) -- degrade: intro file only + end + else + pcall(src.setLooping, src, wantLoop) + end + applyVolume(src) + applyFilter(src) + -- a fanfare owns the music channels: hold the new song until it ends + -- (update() starts it, like the paused-song resume) + if fanfareActive() then + state.fanfareResume = true + else + pcall(src.play, src) + end + state.source = src + state.current = song +end + +function Music.stop() + stopSource(state.source) + stopSource(state.loopSource) + require("src.core.ChipAudio").stopMusic() + state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil +end + +-- Ramp the current song's volume to silence, then stop it, mirroring the +-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio + +-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in +-- integer levels, one level every `control` frames, and the music stops +-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM +-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames +-- to silence). Ticked once per frame from Music.update(). +function Music.fadeOut(control) + if not state.enabled then return end + if not state.source then Music.stop() return end + control = math.max(1, control or 10) + state.fade = { + control = control, + counter = control, -- frames until the next volume step + level = 7, -- current master-volume level (rAUDVOL nibble) + from = VOLUME * volumeScale, -- level-7 (full) source volume + } +end + +-- the song a map should currently play, honoring the bike/surf overrides +local function effectiveMapSong(data, song) + if state.onBike and song and OUTDOOR[song] + and songDef(data, "Music_BikeRiding") then + return "Music_BikeRiding" + end + if state.surfing and song and OUTDOOR[song] + and songDef(data, "Music_Surfing") then + return "Music_Surfing" + end + return song +end + +-- overworld map theme; onBike/surfing override outdoor themes with the +-- bike/surf songs and restore the map theme when they end +function Music.playMap(data, mapId, onBike, surfing) + local song = data and data.audio and data.audio.mapSongs + and mapId and data.audio.mapSongs[mapId] or nil + state.mapSong = song + state.onBike = not not onBike + state.surfing = not not surfing + local play = effectiveMapSong(data, song) + if play then Music.play(data, play) end +end + +-- toggle the surf override mid-map (starting/ending a surf) +function Music.setSurfing(data, surfing) + state.surfing = not not surfing + local play = effectiveMapSong(data, state.mapSong) + if play then Music.play(data, play) end +end + +-- battle themes; kind = "wild"|"trainer"|"gym"|"final" +function Music.playBattle(data, kind) + local b = data.audio and data.audio.battle + if b then Music.play(data, b[kind] or b.wild) end +end + +-- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the +-- moment the win is decided and loops until the battle screen closes +-- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's +-- finish() restores the map theme, like the overworld reload's +-- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started. +function Music.playVictory(data, kind) + local b = data.audio and data.audio.battle + local jingle = b and b[kind .. "Win"] + local def = jingle and songDef(data, jingle) + if def and (def.file or (data.audio and data.audio.runtime)) then + Music.play(data, jingle) + return true + end + return false +end + +-- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme +-- resumes when it ends, via update() +function Music.playOnce(data, song) + local def = songDef(data, song) + if not (def and (def.file or (data.audio and data.audio.runtime))) then + return false + end + Music.play(data, song, false) + state.pendingRestore = true + return true +end + +-- is a playOnce jingle still sounding? (AnimateHealingMachine's +-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) +function Music.oneShotPlaying() + if not state.pendingRestore then return false end + local src = state.source + if not src then return false end + local ok, playing = pcall(src.isPlaying, src) + return ok and playing or false +end + +function Music.restoreMap(data) + state.current = nil + state.pendingRestore = nil + local play = effectiveMapSong(data, state.mapSong) + if play then Music.play(data, play) end +end + +-- 0-7 music volume (0 mutes), applied to the playing song and the +-- queued loop body as well as everything played later +function Music.setVolumeLevel(level) + volumeScale = math.max(0, math.min(7, level or 7)) / 7 + applyVolume(state.source) + applyVolume(state.loopSource) +end + +-- music low-pass filter level, 0 (OFF) to 3 +function Music.setFilterLevel(level) + filterLevel = math.max(0, math.min(3, level or 0)) + applyFilter(state.source) + applyFilter(state.loopSource) +end + +-- re-apply persisted audio options (Game calls this on boot and after +-- loading a save) +function Music.applyOptions(opts) + Music.setVolumeLevel(opts and opts.musicVol or 7) + Music.setFilterLevel(opts and opts.musicFilter or 0) +end + +local function sourceStopped(src) + if not src then return false end + local ok, playing = pcall(src.isPlaying, src) + return ok and not playing +end + +-- call once per frame: chains a finished intro into its loop body and +-- restores the map theme after a one-shot jingle +function Music.update(data) + if data and data.audio and data.audio.runtime then + require("src.core.ChipAudio").update() + end + if not state.enabled then return end + -- volume ramp (Music.fadeOut): hold the current level for `control` + -- frames, then drop one level (FadeOutAudio decrements both rAUDVOL + -- nibbles when its counter reaches 0); at level 0 the music stops. + if state.fade then + local f = state.fade + f.counter = f.counter - 1 + if f.counter <= 0 then + f.counter = f.control + f.level = f.level - 1 + if f.level <= 0 then + state.fade = nil + Music.stop() + return + end + local vol = f.from * f.level / 7 + if state.source then pcall(state.source.setVolume, state.source, vol) end + if state.loopSource then + pcall(state.loopSource.setVolume, state.loopSource, vol) + end + end + return + end + -- while a fanfare plays the song stays paused (a paused source reads + -- as stopped, so the intro-chain/restore checks below must not run); + -- when it ends, the song picks up where it left off + if state.fanfare then + if fanfareActive() then return end + if state.fanfareResume and state.source then + pcall(state.source.play, state.source) + end + state.fanfareResume = false + end + if data and data.audio and data.audio.runtime and not state.fanfare then + require("src.core.ChipAudio").ensureMusicPlaying() + end + if state.loopSource and sourceStopped(state.source) then + local loopSrc = state.loopSource + state.loopSource = nil + state.source = loopSrc + pcall(loopSrc.play, loopSrc) + end + if state.pendingRestore and sourceStopped(state.source) + and not state.loopSource then + Music.restoreMap(data) + end +end + +return Music diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua new file mode 100644 index 00000000..a341347c --- /dev/null +++ b/src/core/SaveData.lua @@ -0,0 +1,217 @@ +-- Save/load via love.filesystem. Game progress lives in save.lua; +-- Options (audio, display, battle preferences) live in a separate +-- options.lua so they survive New Game and aren't tied to a save slot. +-- Both are plain Lua tables serialized as Lua source (deterministic +-- key order). + +local Logger = require("src.core.Logger") + +local SaveData = {} + +local FILENAME = "save.lua" +local OPTIONS_FILENAME = "options.lua" + +-- Port + original Options menu defaults. Missing keys on load are filled +-- from this table so old options.lua files stay compatible. +function SaveData.defaultOptions() + return { + -- textSpeed 3 = MEDIUM, matching InitOptions' TEXT_DELAY_MEDIUM + -- in wOptions (engine/menus/main_menu.asm) + textSpeed = 3, + animations = true, + battleStyle = "shift", + ruleset = "gen1_faithful", + -- 0-7 like the GB's NR50 master volume + musicVol = 7, + sfxVol = 7, + musicFilter = 0, + -- port display options (OptionsMenu / hotkeys 2/3/5) + colors = "gbc", + tilt = 0, + gbcfx = 0, + -- Native mod enablement is an installation option, not save-slot data. + -- Missing entries mean enabled so newly installed mods work by default. + mods = {}, + } +end + +-- Merge loaded keys over defaults (shallow). Unknown keys are kept so +-- future options aren't dropped by older builds writing the file back. +function SaveData.mergeOptions(loaded) + local opts = SaveData.defaultOptions() + if type(loaded) == "table" then + for k, v in pairs(loaded) do + opts[k] = v + end + end + return opts +end + +local function serialize(v, indent) + indent = indent or 0 + local pad = string.rep(" ", indent) + local t = type(v) + if t == "number" or t == "boolean" then + return tostring(v) + elseif t == "string" then + return string.format("%q", v) + elseif t == "table" then + local keys = {} + for k in pairs(v) do table.insert(keys, k) end + table.sort(keys, function(a, b) + local ta, tb = type(a), type(b) + if ta ~= tb then return ta < tb end + return a < b + end) + if next(v) == nil then return "{}" end + local parts = {} + for _, k in ipairs(keys) do + local key + if type(k) == "string" and k:match("^[%a_][%w_]*$") then + key = k + else + key = "[" .. serialize(k) .. "]" + end + table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1)) + end + return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}" + end + error("cannot serialize " .. t) +end + +function SaveData.encode(data) + return "return " .. serialize(data) .. "\n" +end + +function SaveData.decode(str) + local loader = loadstring or load + local chunk, err = loader(str, "@save.lua") + if not chunk then return nil, err end + local ok, data = pcall(chunk) + if not ok then return nil, data end + if type(data) ~= "table" then return nil, "save root must be a table" end + return data +end + +function SaveData.saveOptions(opts) + opts = SaveData.mergeOptions(opts) + local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts)) + if not ok then + Logger.error("options save failed: %s", tostring(err)) + end + return ok and opts or nil +end + +function SaveData.loadOptions() + if not love.filesystem.getInfo(OPTIONS_FILENAME) then + return SaveData.defaultOptions() + end + local chunk, err = love.filesystem.load(OPTIONS_FILENAME) + if not chunk then + Logger.error("options load failed: %s", tostring(err)) + return SaveData.defaultOptions() + end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then + Logger.error("options load failed: %s", tostring(data)) + return SaveData.defaultOptions() + end + return SaveData.mergeOptions(data) +end + +-- Game progress only; options are written separately via saveOptions. +-- If `data.options` is present it is also flushed to options.lua so an +-- F1 / in-game save keeps the live settings in sync, then stripped from +-- the game file. +function SaveData.save(data) + if data.options then + SaveData.saveOptions(data.options) + end + local gameOnly = {} + for k, v in pairs(data) do + if k ~= "options" then gameOnly[k] = v end + end + local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly)) + if ok then + Logger.info("saved game") + else + Logger.error("save failed: %s", tostring(err)) + end + return ok +end + +function SaveData.load() + if not love.filesystem.getInfo(FILENAME) then + return nil + end + local chunk, err = love.filesystem.load(FILENAME) + if not chunk then + Logger.error("load failed: %s", tostring(err)) + return nil + end + local ok, data = pcall(chunk) + if not ok then + Logger.error("load failed: %s", tostring(data)) + return nil + end + -- saves from before the trainer ID existed: backfill once on load + -- (like the OT backfill for old saves) + if data.player and not data.player.id then + data.player.id = math.random(0, 65535) + end + -- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object + -- was already hidden (Snorlax beaten) but the flag was never added, + -- and it can never be set again since the hidden object is + -- unreachable -- backfill it from the toggle so it isn't stuck forever + if data.objectToggles and data.flags then + local snorlaxRoutes = { + { map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" }, + { map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" }, + } + for _, r in ipairs(snorlaxRoutes) do + local toggles = data.objectToggles[r.map] + if toggles and toggles[r.obj] == false and not data.flags[r.flag] then + data.flags[r.flag] = true + end + end + end + -- Migrate options that still live inside an old save.lua into the + -- standalone options file (once), then always prefer options.lua. + if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then + SaveData.saveOptions(data.options) + end + data.options = SaveData.loadOptions() + Logger.info("loaded save") + return data +end + +function SaveData.newGame() + return { + player = { + map = "PALLET_TOWN", + x = 5, + y = 6, + facing = "down", + name = "RED", + rival = "BLUE", + -- 16-bit trainer ID rolled at new game (wPlayerID, filled from + -- hRandomAdd in OakSpeech) + id = math.random(0, 65535), + }, + flags = {}, + inventory = {}, + party = {}, + box = {}, + money = 3000, + defeatedTrainers = {}, + pokedex = { seen = {}, owned = {} }, + -- where blackouts and ESCAPE ROPE return to (updated by nurses) + lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 }, + repelSteps = 0, + -- Live options from options.lua (or defaults); New Game keeps the + -- player's audio/display/battle preferences. + options = SaveData.loadOptions(), + } +end + +return SaveData diff --git a/src/core/Sound.lua b/src/core/Sound.lua new file mode 100644 index 00000000..a5d6c7e8 --- /dev/null +++ b/src/core/Sound.lua @@ -0,0 +1,222 @@ +-- Sound effects and cries synthesized from compact ROM channel programs or +-- loaded from legacy static audio definitions. Sources are cached; headless +-- use is a safe no-op. + +local Sound = {} + +local cache = {} +local enabled = true +-- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), +-- scaling the 0.8 base every source gets +local BASE_VOLUME = 0.8 +local volumeScale = 1 + +-- Fanfares occupy the music's tone channels on the Game Boy: their sfx +-- headers claim channels 5-7 (= hardware channels 1-3), silencing the +-- song until they finish (audio/headers/sfxheaders*.asm; the game also +-- blocks on them via PlaySoundWaitForCurrent/WaitForSoundToFinish). +-- The Poké Flute even issues SFX_STOP_ALL_MUSIC first +-- (engine/items/item_effects.asm). Music.lua pauses the current song +-- while one of these plays and resumes it afterwards. Ordinary short +-- SFX (menu beeps, hits, cries) stay overlaid. +local FANFARES = { + Level_Up = true, + Caught_Mon = true, + Get_Item1 = true, + Get_Item2 = true, + Get_Key_Item = true, + Pokedex_Rating = true, + Dex_Page_Added = true, + Pokeflute = true, +} + +local function playPath(data, key, path, pitch, tempo) + if not enabled or not love.audio or not path then return nil end + local src = cache[key] + if not src then + local ok, s + if data.audio and data.audio.runtime and type(path) == "table" then + ok, s = pcall( + require("src.core.ChipAudio").newSfx, + data, key:match("^([^@]+)") or key, pitch, tempo, path) + else + ok, s = pcall(love.audio.newSource, path, "static") + end + if not ok or not s then + enabled = false + return nil + end + s:setVolume(BASE_VOLUME * volumeScale) + cache[key] = s + src = s + end + src:stop() + src:play() + return src +end + +function Sound.play(data, name) + local sfx = data.audio and data.audio.sfx + local src = playPath(data, name, sfx and sfx[name]) + if src and FANFARES[name] then + require("src.core.Music").duckForFanfare(src) + end +end + +-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers +-- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/ +-- wTempoModifier and the battle sound engine applies them to every +-- battle SFX -- audio/engine_2.asm Audio2_ApplyFrequencyModifier/ +-- Audio2_SetSfxTempo). The extractor pre-synthesizes one WAV per +-- distinct (sfx, pitch, tempo) as "@" keys in the +-- sfx table; older audio.lua builds without the variants fall back to +-- the unmodified sound. +-- anim: a moves.lua anim table { sound, pitch, tempo }. +function Sound.playMove(data, anim) + if not anim or not anim.sound then return end + local sfx = data.audio and data.audio.sfx + if not sfx then return end + local name = anim.sound + local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 + if data.audio.runtime and sfx[name] then + playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), + sfx[name], pitch, tempo) + return + end + if pitch ~= 0 or tempo ~= 0x80 then + local key = ("%s@%02x%02x"):format(name, pitch, tempo) + if sfx[key] then + playPath(data, key, sfx[key]) + return + end + end + playPath(data, name, sfx[name]) +end + +function Sound.playCry(data, species) + local cries = data.audio and data.audio.cries + -- returns the source (nil headless) so callers that block on the cry + -- like the original's PlayCry -> WaitForSoundToFinish can poll it + local definition = cries and cries[species] + if data.audio and data.audio.runtime and definition then + local key = "cry:" .. tostring(species) + local src = cache[key] + if not src then + local ok, generated = pcall( + require("src.core.ChipAudio").newCry, data, species) + if not ok or not generated then return nil end + generated:setVolume(BASE_VOLUME * volumeScale) + cache[key] = generated + src = generated + end + src:stop() + src:play() + return src + end + return playPath(data, "cry:" .. tostring(species), definition) +end + +-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks +-- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/ +-- tempo bytes on top of the cry's species modifiers before the tempo +-- register is set (Audio2_SetSfxTempo: tempo9bit = wTempoModifier+$80). +-- $80 is the table's "no extra shift" tempo byte (every other move's +-- entry defaults to it), so the two moves' own bytes -- Growl's $c0, +-- Roar's $40 -- are the *extra* shift on top of whatever the species' +-- cry already sounds like. The generated cry source already includes the +-- species' pitch/tempo, so layer the move's extra shift on with +-- Source:setPitch (pitch mod is left unmodeled: both moves set it $00). +function Sound.playMoveCry(data, species, tempoMod) + local src = Sound.playCry(data, species) + if src and tempoMod and tempoMod ~= 0x80 then + pcall(src.setPitch, src, 256 / (128 + tempoMod)) + end + return src +end + +-- is a previously played one-shot still sounding? (ShakeElevator's +-- .musicLoop polls wChannelSoundIDs+CHAN5 until SFX_SAFARI_ZONE_PA +-- ends.) Headless / never-played names read as silent. +function Sound.isPlaying(name) + local src = cache[name] + if not src then return false end + local ok, playing = pcall(src.isPlaying, src) + return ok and playing or false +end + +-- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the +-- elevator shake stop the last collision thud mid-ring) +function Sound.stop(name) + local src = cache[name] + if src then pcall(src.stop, src) end +end + +-- Looping sources (the low-health alarm): started/stopped by game +-- states. ChipAudio generates the two-tone siren used by runtime imports; +-- legacy data can still provide a looping static source. +local loopCache = {} +local looping = {} + +function Sound.startLoop(data, name) + if looping[name] then return end + local sfx = data.audio and data.audio.sfx + local path = sfx and sfx[name] + local runtimeAlarm = data.audio and data.audio.runtime + and name == "Low_Health_Alarm" + if not enabled or not love.audio or (not path and not runtimeAlarm) then + return + end + local src = loopCache[name] + if not src then + local ok, s + if data.audio.runtime and name == "Low_Health_Alarm" then + ok, s = pcall(require("src.core.ChipAudio").newLowHealthAlarm) + elseif data.audio.runtime and type(path) == "table" then + ok, s = pcall( + require("src.core.ChipAudio").newSfx, data, name) + else + ok, s = pcall(love.audio.newSource, path, "static") + end + if not ok then return end + s:setLooping(true) + s:setVolume(BASE_VOLUME * volumeScale) + loopCache[name] = s + src = s + end + src:play() + looping[name] = src +end + +function Sound.stopLoop(name) + local src = looping[name] + if src then + pcall(src.stop, src) + looping[name] = nil + end +end + +-- is a looping source currently sounding? (drivers assert on this) +function Sound.isLooping(name) + return looping[name] ~= nil +end + +-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries, +-- the low-health alarm loop) update immediately so the change is heard +-- on the next play +function Sound.setVolumeLevel(level) + volumeScale = math.max(0, math.min(7, level or 7)) / 7 + for _, src in pairs(cache) do + pcall(src.setVolume, src, BASE_VOLUME * volumeScale) + end + for _, src in pairs(loopCache) do + pcall(src.setVolume, src, BASE_VOLUME * volumeScale) + end +end + +-- re-apply persisted audio options (Game calls this on boot and after +-- loading a save) +function Sound.applyOptions(opts) + Sound.setVolumeLevel(opts and opts.sfxVol or 7) +end + +return Sound diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua new file mode 100644 index 00000000..321f213a --- /dev/null +++ b/src/core/StateStack.lua @@ -0,0 +1,45 @@ +-- Game state stack. The top state updates; all states draw bottom-up +-- (so a text box can overlay the overworld, a battle replaces it, etc). +-- States are tables with optional enter/exit/update/draw/isOpaque. + +local StateStack = {} + +function StateStack:init() + self.states = {} +end + +function StateStack:push(state, ...) + table.insert(self.states, state) + if state.enter then state:enter(...) end +end + +function StateStack:pop() + local state = table.remove(self.states) + if state and state.exit then state:exit() end + return state +end + +function StateStack:top() + return self.states[#self.states] +end + +function StateStack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end +end + +-- index of the lowest state drawn this frame (highest opaque, else 1) +function StateStack:visibleBase() + for i = #self.states, 1, -1 do + if self.states[i].isOpaque then return i end + end + return 1 +end + +function StateStack:draw() + for i = self:visibleBase(), #self.states do + if self.states[i].draw then self.states[i]:draw() end + end +end + +return StateStack diff --git a/src/core/TouchInput.lua b/src/core/TouchInput.lua new file mode 100644 index 00000000..9e4a350f --- /dev/null +++ b/src/core/TouchInput.lua @@ -0,0 +1,282 @@ +-- Touch gesture recognizer → virtual keyboard keys for Input.lua. +-- +-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second +-- tap. That adds ~280ms latency to every A press so a double-tap can be +-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect +-- input needs, so the latency is acceptable. +-- +-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct +-- touch ID lands while another short, low-movement touch is active, fire +-- SELECT (press + one-frame auto-release). + +local Input = require("src.core.Input") + +local TouchInput = {} + +local function dpiScale() + if love and love.window then + if love.window.getDPIScale then + return love.window.getDPIScale() + end + if love.window.toPixels then + return love.window.toPixels(1) + end + end + return 1 +end + +-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing. +local SWIPE_THRESHOLD_PX = 14 +local EDGE_PX = 24 +local EDGE_SWIPE_PX = 24 +local DOUBLE_TAP_MS = 280 +local TAP_MAX_MS = 320 +local TAP_MAX_MOVE_PX = 12 + +local function scaled(px) + return px * dpiScale() +end + +local DIRS = { up = true, down = true, left = true, right = true } + +-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names). +local KEY = { + up = "up", + down = "down", + left = "left", + right = "right", + a = "z", + b = "x", + start = "escape", + select = "rshift", +} + +local function nowMs() + return love.timer.getTime() * 1000 +end + +local function dominantDir(dx, dy) + if math.abs(dx) >= math.abs(dy) then + return dx > 0 and "right" or "left" + end + return dy > 0 and "down" or "up" +end + +function TouchInput:init() + self.touches = {} + self.pendingA = nil -- { deadlineMs = number } + -- Edge pulses (B / START / SELECT / deferred-A): press now, release on a + -- later update so FixedStep can consume wasPressed first. + -- `armed` = pressed during events since last update; promoted to + -- `autoRelease` at the start of update (released on the *following* update). + -- Deferred-A fired inside update goes straight into `autoRelease`. + self.armed = {} + self.autoRelease = {} + self.selectFired = false -- one SELECT per two-finger gesture cluster +end + +local function pulse(self, key) + Input:keypressed(key) + self.armed[#self.armed + 1] = key +end + +local function pulseInUpdate(self, key) + Input:keypressed(key) + self.autoRelease[#self.autoRelease + 1] = key +end + +local function releaseDir(self, touch) + if touch.dir and DIRS[touch.dir] then + Input:keyreleased(KEY[touch.dir]) + touch.dir = nil + end +end + +local function pressDir(self, touch, dir) + if touch.dir == dir then return end + releaseDir(self, touch) + touch.dir = dir + Input:keypressed(KEY[dir]) +end + +local function totalMove(touch, x, y) + local dx = x - touch.x0 + local dy = y - touch.y0 + return math.abs(dx), math.abs(dy), dx, dy +end + +local function isTapLike(touch, x, y, tMs) + local ax, ay = totalMove(touch, x, y) + local elapsed = tMs - touch.t0 + return elapsed <= TAP_MAX_MS + and ax <= scaled(TAP_MAX_MOVE_PX) + and ay <= scaled(TAP_MAX_MOVE_PX) + and not touch.classified +end + +local function countActive(self) + local n = 0 + for _ in pairs(self.touches) do n = n + 1 end + return n +end + +local function tryTwoFingerSelect(self, tMs) + if self.selectFired then return false end + local ids = {} + for id, touch in pairs(self.touches) do + if isTapLike(touch, touch.x, touch.y, tMs) then + ids[#ids + 1] = id + end + end + if #ids < 2 then return false end + + self.selectFired = true + self.pendingA = nil + for _, id in ipairs(ids) do + local touch = self.touches[id] + touch.classified = true + touch.consumed = true + releaseDir(self, touch) + end + pulse(self, KEY.select) + return true +end + +function TouchInput:update(dt) + -- Releases armed on a prior update (FixedStep already saw wasPressed). + for i = 1, #self.autoRelease do + Input:keyreleased(self.autoRelease[i]) + end + -- Promote event-phase pulses from since the last update; they release next time. + self.autoRelease = self.armed + self.armed = {} + + local tMs = nowMs() + + -- Deferred A: fire once the double-tap window closes with no second tap. + -- Queued into autoRelease so the next update clears hold after this FixedStep. + if self.pendingA and tMs >= self.pendingA.deadlineMs then + self.pendingA = nil + pulseInUpdate(self, KEY.a) + end + + -- Keep two-finger SELECT detection live while both fingers stay down. + if countActive(self) >= 2 then + tryTwoFingerSelect(self, tMs) + elseif countActive(self) == 0 then + self.selectFired = false + end +end + +function TouchInput:touchpressed(id, x, y) + local tMs = nowMs() + + -- Second tap inside the deferred-A window → START instead of A. + if self.pendingA and tMs < self.pendingA.deadlineMs then + self.pendingA = nil + pulse(self, KEY.start) + -- Still record this touch so a lingering finger doesn't become a stray swipe. + self.touches[id] = { + x0 = x, y0 = y, x = x, y = y, t0 = tMs, + edge = x < scaled(EDGE_PX), + classified = true, + consumed = true, + dir = nil, + } + return + end + + self.touches[id] = { + x0 = x, y0 = y, x = x, y = y, t0 = tMs, + edge = x < scaled(EDGE_PX), + classified = false, + consumed = false, + dir = nil, + } + + if countActive(self) >= 2 then + tryTwoFingerSelect(self, tMs) + end +end + +function TouchInput:touchmoved(id, x, y) + local touch = self.touches[id] + if not touch or touch.consumed then return end + + touch.x, touch.y = x, y + local ax, ay, dx, dy = totalMove(touch, x, y) + local swipeTh = scaled(SWIPE_THRESHOLD_PX) + + -- Edge-origin swipes become B on release; never promote to d-pad. + if touch.edge then + if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then + touch.classified = true + end + return + end + + if not touch.classified then + if ax < swipeTh and ay < swipeTh then return end + touch.classified = true + pressDir(self, touch, dominantDir(dx, dy)) + return + end + + -- Mid-hold direction change: release old, press new (dominant axis). + if touch.dir then + local fromLastX = x - touch.x0 + local fromLastY = y - touch.y0 + -- Re-evaluate from origin so small jitter doesn't flip; require threshold + -- distance from origin along the new dominant axis. + if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then + local newDir = dominantDir(fromLastX, fromLastY) + if newDir ~= touch.dir then + pressDir(self, touch, newDir) + end + end + end +end + +function TouchInput:touchreleased(id, x, y) + local touch = self.touches[id] + if not touch then return end + + local tMs = nowMs() + touch.x, touch.y = x, y + local ax, ay = totalMove(touch, x, y) + + if touch.dir then + releaseDir(self, touch) + self.touches[id] = nil + if countActive(self) == 0 then self.selectFired = false end + return + end + + if touch.consumed then + self.touches[id] = nil + if countActive(self) == 0 then self.selectFired = false end + return + end + + -- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX + -- (or already marked classified while moving). Prefer over d-pad / tap. + if touch.edge then + local edgeTh = scaled(EDGE_SWIPE_PX) + if touch.classified or ax >= edgeTh or ay >= edgeTh then + pulse(self, KEY.b) + self.touches[id] = nil + if countActive(self) == 0 then self.selectFired = false end + return + end + end + + -- Plain tap → defer A (or it was already classified as swipe without dir, ignore). + if isTapLike(touch, x, y, tMs) then + self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS } + end + + self.touches[id] = nil + if countActive(self) == 0 then self.selectFired = false end +end + +return TouchInput diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua new file mode 100644 index 00000000..6b6a589d --- /dev/null +++ b/src/import/ImageWriter.lua @@ -0,0 +1,143 @@ +local ImageWriter = {} + +local SHADES = { + { 1, 1, 1, 1 }, + { 2 / 3, 2 / 3, 2 / 3, 1 }, + { 1 / 3, 1 / 3, 1 / 3, 1 }, + { 0, 0, 0, 1 }, +} + +local function assertDimensions(raw, width, height, bits) + assert(width % 8 == 0 and height % 8 == 0, + ("%dbpp dimensions must be tile-aligned: %dx%d") + :format(bits, width, height)) + local expected = width * height * bits / 8 + assert(#raw == expected, + ("%dbpp payload is %d bytes, expected %d") + :format(bits, #raw, expected)) +end + +function ImageWriter.decode2bpp(raw, width, height, transparent) + assertDimensions(raw, width, height, 2) + local image = love.image.newImageData(width, height) + local tilesPerRow = width / 8 + for tile = 0, #raw / 16 - 1 do + local tileX = tile % tilesPerRow * 8 + local tileY = math.floor(tile / tilesPerRow) * 8 + for y = 0, 7 do + local low = raw[tile * 16 + y * 2 + 1] + local high = raw[tile * 16 + y * 2 + 2] + for x = 0, 7 do + local divisor = 2 ^ (7 - x) + local shade = math.floor(high / divisor) % 2 * 2 + + math.floor(low / divisor) % 2 + local color = SHADES[shade + 1] + local alpha = color[4] + if transparent and shade == 0 then alpha = 0 end + image:setPixel(tileX + x, tileY + y, + color[1], color[2], color[3], alpha) + end + end + end + return image +end + +function ImageWriter.decode1bpp(raw, width, height, transparent) + assertDimensions(raw, width, height, 1) + local image = love.image.newImageData(width, height) + local tilesPerRow = width / 8 + for tile = 0, #raw / 8 - 1 do + local tileX = tile % tilesPerRow * 8 + local tileY = math.floor(tile / tilesPerRow) * 8 + for y = 0, 7 do + local row = raw[tile * 8 + y + 1] + for x = 0, 7 do + local filled = math.floor(row / 2 ^ (7 - x)) % 2 ~= 0 + local value = filled and 0 or 1 + local alpha = 1 + if transparent and not filled then alpha = 0 end + image:setPixel(tileX + x, tileY + y, + value, value, value, alpha) + end + end + end + return image +end + +function ImageWriter.blank(width, height, r, g, b, a) + local image = love.image.newImageData(width, height) + image:mapPixel(function() return r or 0, g or 0, b or 0, a or 0 end) + return image +end + +function ImageWriter.blit(target, source, targetX, targetY, + sourceX, sourceY, width, height, flipX) + sourceX, sourceY = sourceX or 0, sourceY or 0 + width, height = width or source:getWidth(), height or source:getHeight() + for y = 0, height - 1 do + for x = 0, width - 1 do + local sampleX = flipX and sourceX + width - 1 - x or sourceX + x + target:setPixel(targetX + x, targetY + y, + source:getPixel(sampleX, sourceY + y)) + end + end +end + +function ImageWriter.matteColor0(image) + local width, height = image:getDimensions() + local queueX, queueY, head = {}, {}, 1 + local seen = {} + local function add(x, y) + local key = y * width + x + if seen[key] then return end + local r, g, b, a = image:getPixel(x, y) + if r == 1 and g == 1 and b == 1 and a == 1 then + seen[key] = true + queueX[#queueX + 1], queueY[#queueY + 1] = x, y + end + end + for x = 0, width - 1 do add(x, 0); add(x, height - 1) end + for y = 0, height - 1 do add(0, y); add(width - 1, y) end + while head <= #queueX do + local x, y = queueX[head], queueY[head] + head = head + 1 + image:setPixel(x, y, 1, 1, 1, 0) + if x > 0 then add(x - 1, y) end + if x + 1 < width then add(x + 1, y) end + if y > 0 then add(x, y - 1) end + if y + 1 < height then add(x, y + 1) end + end + return image +end + +function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile) + bytesPerTile = bytesPerTile or 16 + local out = {} + for y = 0, tilesHigh - 1 do + for x = 0, tilesWide - 1 do + local source = (x * tilesHigh + y) * bytesPerTile + local target = (y * tilesWide + x) * bytesPerTile + for offset = 1, bytesPerTile do + out[target + offset] = raw[source + offset] + end + end + end + return out +end + +function ImageWriter.save(image, path) + local parent = path:match("^(.*)/[^/]+$") + if parent then + local ok, err = love.filesystem.createDirectory(parent) + if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end + end + local ok, fileData = pcall(image.encode, image, "png") + if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end + local written, writeError = love.filesystem.write(path, fileData) + if not written then + error("could not write " .. path .. ": " .. tostring(writeError)) + end + return fileData +end + +return ImageWriter diff --git a/src/import/LuaWriter.lua b/src/import/LuaWriter.lua new file mode 100644 index 00000000..749bf129 --- /dev/null +++ b/src/import/LuaWriter.lua @@ -0,0 +1,99 @@ +local LuaWriter = {} + +local KEYWORDS = { + ["and"] = true, ["break"] = true, ["do"] = true, ["else"] = true, + ["elseif"] = true, ["end"] = true, ["false"] = true, ["for"] = true, + ["function"] = true, ["goto"] = true, ["if"] = true, ["in"] = true, + ["local"] = true, ["nil"] = true, ["not"] = true, ["or"] = true, + ["repeat"] = true, ["return"] = true, ["then"] = true, ["true"] = true, + ["until"] = true, ["while"] = true, +} + +local function quote(value) + local escaped = value:gsub('[%z\1-\31\\"]', function(character) + if character == "\\" then return "\\\\" end + if character == '"' then return '\\"' end + if character == "\n" then return "\\n" end + if character == "\r" then return "\\r" end + if character == "\t" then return "\\t" end + return ("\\%03d"):format(character:byte()) + end) + return '"' .. escaped .. '"' +end + +local function keyText(key) + if type(key) == "string" + and key:match("^[A-Za-z_][A-Za-z0-9_]*$") + and not KEYWORDS[key] then + return key + end + return "[" .. (type(key) == "string" and quote(key) or tostring(key)) .. "]" +end + +local function isArray(value) + local count, maximum = 0, 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return false, 0 + end + count = count + 1 + maximum = math.max(maximum, key) + end + return count == maximum, maximum +end + +local function sortedKeys(value) + local keys = {} + for key in pairs(value) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) + if type(a) == type(b) then return a < b end + return type(a) == "number" + end) + return keys +end + +local function encode(value, indent, seen) + local kind = type(value) + if value == nil then return "nil" end + if kind == "boolean" or kind == "number" then return tostring(value) end + if kind == "string" then return quote(value) end + if kind ~= "table" then + error("cannot serialize " .. kind) + end + if seen[value] then error("cannot serialize a cyclic table") end + seen[value] = true + + local pad = string.rep(" ", indent) + local childPad = string.rep(" ", indent + 1) + local out = {} + local array, length = isArray(value) + if array then + for index = 1, length do + out[#out + 1] = childPad .. encode(value[index], indent + 1, seen) .. "," + end + else + for _, key in ipairs(sortedKeys(value)) do + out[#out + 1] = childPad .. keyText(key) .. " = " + .. encode(value[key], indent + 1, seen) .. "," + end + end + seen[value] = nil + if #out == 0 then return "{}" end + return "{\n" .. table.concat(out, "\n") .. "\n" .. pad .. "}" +end + +function LuaWriter.encode(value) + return "return " .. encode(value, 0, {}) .. "\n" +end + +function LuaWriter.write(path, value) + local parent = path:match("^(.*)/[^/]+$") + if parent then + local ok, err = love.filesystem.createDirectory(parent) + if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end + end + local ok, err = love.filesystem.write(path, LuaWriter.encode(value)) + if not ok then error("could not write " .. path .. ": " .. tostring(err)) end +end + +return LuaWriter diff --git a/src/import/Rom.lua b/src/import/Rom.lua new file mode 100644 index 00000000..6eb27ed6 --- /dev/null +++ b/src/import/Rom.lua @@ -0,0 +1,211 @@ +local Rom = {} +Rom.__index = Rom + +local BANK_SIZE = 0x4000 + +function Rom.new(data) + assert(type(data) == "string", "ROM data must be a string") + return setmetatable({ data = data }, Rom) +end + +function Rom.offset(bank, address) + if bank == 0 then + assert(address >= 0 and address < BANK_SIZE, + ("ROM0 address out of range: $%04X"):format(address)) + return address + end + assert(address >= BANK_SIZE and address < BANK_SIZE * 2, + ("bank %02X address out of range: $%04X"):format(bank, address)) + return bank * BANK_SIZE + address - BANK_SIZE +end + +function Rom:byte(bank, address) + local value = self.data:byte(Rom.offset(bank, address) + 1) + assert(value, ("ROM read past end at %02X:%04X"):format(bank, address)) + return value +end + +function Rom:word(bank, address) + return self:byte(bank, address) + self:byte(bank, address + 1) * 0x100 +end + +function Rom:bytes(bank, address, length) + local first = Rom.offset(bank, address) + 1 + local last = first + length - 1 + assert(last <= #self.data, + ("ROM read past end at %02X:%04X + %d"):format(bank, address, length)) + local out = {} + for index = 1, length do + out[index] = self.data:byte(first + index - 1) + end + return out +end + +function Rom:decodeText(raw, charmap, stop) + local out = {} + stop = stop or 0x50 + for _, value in ipairs(raw) do + if value == stop then break end + out[#out + 1] = charmap[tostring(value)] + or ("{BYTE:%02X}"):format(value) + end + return table.concat(out) +end + +function Rom:readString(bank, address, charmap, stop, maxLength) + local out = {} + stop = stop or 0x50 + maxLength = maxLength or 4096 + for offset = 0, maxLength - 1 do + local value = self:byte(bank, address + offset) + if value == stop then return table.concat(out), offset + 1 end + out[#out + 1] = charmap[tostring(value)] + or ("{BYTE:%02X}"):format(value) + end + error(("unterminated string at %02X:%04X"):format(bank, address)) +end + +function Rom.bcd(raw) + local value = 0 + for _, byte in ipairs(raw) do + value = value * 100 + math.floor(byte / 16) * 10 + byte % 16 + end + return value +end + +local BitReader = {} +BitReader.__index = BitReader + +function BitReader.new(data) + return setmetatable({ data = data, byte = 1, bit = 7 }, BitReader) +end + +function BitReader:read(count) + local value = 0 + for _ = 1, count or 1 do + local byte = self.data[self.byte] + if not byte then error("compressed picture ended unexpectedly") end + value = value * 2 + math.floor(byte / 2 ^ self.bit) % 2 + self.bit = self.bit - 1 + if self.bit < 0 then + self.byte = self.byte + 1 + self.bit = 7 + end + end + return value +end + +local function fillPicPlane(reader, width) + local mode = reader:read() + local groupCount = width * width * 0x20 + local groups = {} + while #groups < groupCount do + if mode ~= 0 then + while #groups < groupCount do + local group = reader:read(2) + if group == 0 then break end + groups[#groups + 1] = group + end + else + local prefix = 0 + while reader:read() ~= 0 do + prefix = prefix + 1 + if prefix >= 16 then error("invalid compressed picture zero run") end + end + local zeroCount = 2 ^ (prefix + 1) - 1 + reader:read(prefix + 1) + for _ = 1, math.min(zeroCount, groupCount - #groups) do + groups[#groups + 1] = 0 + end + end + mode = 1 - mode + end + + local reordered = {} + for y = 0, width - 1 do + for x = 0, width * 8 - 1 do + for group = 0, 3 do + local source = (y * 4 + group) * width * 8 + x + reordered[#reordered + 1] = groups[source + 1] + end + end + end + local packed = {} + for index = 0, width * width * 8 - 1 do + local start = index * 4 + packed[index + 1] = reordered[start + 1] * 0x40 + + reordered[start + 2] * 0x10 + + reordered[start + 3] * 4 + + reordered[start + 4] + end + return packed +end + +local PIC_CODES = { + { 0x0, 0x1, 0x3, 0x2, 0x7, 0x6, 0x4, 0x5, + 0xF, 0xE, 0xC, 0xD, 0x8, 0x9, 0xB, 0xA }, + { 0xF, 0xE, 0xC, 0xD, 0x8, 0x9, 0xB, 0xA, + 0x0, 0x1, 0x3, 0x2, 0x7, 0x6, 0x4, 0x5 }, +} + +local function unfilterPicPlane(plane, width) + for x = 0, width * 8 - 1 do + local bit = 0 + for y = 0, width - 1 do + local index = y * width * 8 + x + 1 + local high = PIC_CODES[bit + 1][math.floor(plane[index] / 16) + 1] + bit = high % 2 + local low = PIC_CODES[bit + 1][plane[index] % 16 + 1] + bit = low % 2 + plane[index] = high * 16 + low + end + end +end + +local function transposePicTiles(data, width) + local tileCount = width * width + for index = 0, tileCount - 1 do + local other = (index * width + math.floor(index / width)) % tileCount + if index < other then + for offset = 1, 16 do + local left = index * 16 + offset + local right = other * 16 + offset + data[left], data[right] = data[right], data[left] + end + end + end +end + +function Rom.decompressPic(data) + local reader = BitReader.new(data) + local width, height = reader:read(4), reader:read(4) + if width == 0 or width ~= height then + error(("compressed picture is not a non-empty square (%dx%d)") + :format(width, height)) + end + + local order = reader:read() + local planes = {} + planes[order + 1] = fillPicPlane(reader, width) + local mode = reader:read() + if mode ~= 0 then mode = mode + reader:read() end + planes[(1 - order) + 1] = fillPicPlane(reader, width) + + unfilterPicPlane(planes[order + 1], width) + if mode ~= 1 then unfilterPicPlane(planes[(1 - order) + 1], width) end + if mode ~= 0 then + for index = 1, width * width * 8 do + planes[(1 - order) + 1][index] = + bit.bxor(planes[(1 - order) + 1][index], planes[order + 1][index]) + end + end + + local output = {} + for index = 1, width * width * 8 do + output[#output + 1] = planes[1][index] + output[#output + 1] = planes[2][index] + end + transposePicTiles(output, width) + return output, width +end + +return Rom diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua new file mode 100644 index 00000000..322bacd8 --- /dev/null +++ b/src/import/RomExtractor.lua @@ -0,0 +1,1696 @@ +local bit = require("bit") +local ImageWriter = require("src.import.ImageWriter") +local LuaWriter = require("src.import.LuaWriter") +local Rom = require("src.import.Rom") + +local RomExtractor = {} +RomExtractor.__index = RomExtractor + +local STAGE_COUNT = 17 + +local function copy(value, seen) + if type(value) ~= "table" then return value end + seen = seen or {} + if seen[value] then return seen[value] end + local result = {} + seen[value] = result + for key, item in pairs(value) do result[copy(key, seen)] = copy(item, seen) end + return result +end + +local function append(target, source) + for _, value in ipairs(source) do target[#target + 1] = value end +end + +local function unique(values) + local result, seen = {}, {} + for _, value in ipairs(values) do + if not seen[value] then + seen[value] = true + result[#result + 1] = value + end + end + return result +end + +local function sorted(values) + table.sort(values) + return values +end + +local function startsWith(value, prefix) + return value:sub(1, #prefix) == prefix +end + +local function round(value) + return math.floor(value + 0.5) +end + +local function hex(prefix, value) + return ("%s_%02X"):format(prefix, value) +end + +function RomExtractor.new(romData, manifest, progress) + return setmetatable({ + rom = Rom.new(romData), + manifest = manifest, + symbols = manifest.symbols, + progress = progress, + stage = 0, + }, RomExtractor) +end + +function RomExtractor:symbol(name) + local location = self.symbols[name] + if not location then error("required symbol is missing: " .. tostring(name)) end + return { bank = location[1], address = location[2], name = name } +end + +function RomExtractor:beginStage(name) + self.stage = self.stage + 1 + if self.progress then self.progress(self.stage - 1, STAGE_COUNT, name, 0, 1) end +end + +function RomExtractor:tick(name, current, total) + if self.progress then + self.progress(self.stage - 1 + current / total, STAGE_COUNT, + name, current, total) + end +end + +function RomExtractor:write(name, value) + LuaWriter.write("data/generated/" .. name .. ".lua", value) +end + +function RomExtractor:save(image, relative) + ImageWriter.save(image, "assets/generated/" .. relative) +end + +function RomExtractor:readTerminated(bank, address, terminator, limit) + local out = {} + for offset = 0, (limit or 256) - 1 do + local value = self.rom:byte(bank, address + offset) + if value == terminator then return out end + out[#out + 1] = value + end + error(("unterminated byte list at %02X:%04X"):format(bank, address)) +end + +function RomExtractor:write2bpp(raw, width, height, relative, transparent) + local image = ImageWriter.decode2bpp(raw, width, height, transparent) + self:save(image, relative) +end + +function RomExtractor:writeCompressedPic(label, relative) + local symbol = self:symbol(label) + local compressed = self.rom:bytes( + symbol.bank, symbol.address, 0x8000 - symbol.address) + local raw, width = Rom.decompressPic(compressed) + local image = ImageWriter.matteColor0( + ImageWriter.decode2bpp(raw, width * 8, width * 8)) + self:save(image, relative) + return width +end + +function RomExtractor:extractConstants() + self:beginStage("Game constants") + local data = self.manifest.constants + self:write("constants", data) + self:tick("Game constants", 1, 1) + return data +end + +function RomExtractor:extractTilesets() + self:beginStage("World tiles") + local manifest = self.manifest + local order = manifest.constants.tilesetOrder + local metadata = manifest.tilesets + local animations = manifest.tileAnimations + assert(#metadata == #order, "tileset metadata count does not match constants") + + local headers = self:symbol("Tilesets") + local warpPointers = self:symbol("WarpTileIDPointers") + local doorPointers = self:symbol("DoorTileIDPointers") + local doors, address = {}, doorPointers.address + while true do + local tilesetId = self.rom:byte(doorPointers.bank, address) + if tilesetId == 0xFF then break end + local pointer = self.rom:word(doorPointers.bank, address + 1) + doors[tilesetId] = self:readTerminated( + doorPointers.bank, pointer, 0) + address = address + 3 + end + + local out, written = {}, {} + for index, constName in ipairs(order) do + local spec = metadata[index] + assert(spec.id == constName, "tileset metadata is out of order") + local rowAddress = headers.address + (index - 1) * 12 + local gfxBank = self.rom:byte(headers.bank, rowAddress) + local blockPointer = self.rom:word(headers.bank, rowAddress + 1) + local gfxPointer = self.rom:word(headers.bank, rowAddress + 3) + local collisionPointer = self.rom:word(headers.bank, rowAddress + 5) + local counters = self.rom:bytes(headers.bank, rowAddress + 7, 3) + local grass = self.rom:byte(headers.bank, rowAddress + 10) + local animationId = self.rom:byte(headers.bank, rowAddress + 11) + assert(animationId < #animations, constName .. ": unknown tile animation") + + local blocksRaw = self.rom:bytes( + gfxBank, blockPointer, spec.blockCount * 16) + local blocks = {} + for offset = 1, #blocksRaw, 16 do + local block = {} + for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end + blocks[#blocks + 1] = block + end + local walkable = sorted(self:readTerminated( + 0, collisionPointer, 0xFF)) + local warpPointer = self.rom:word( + warpPointers.bank, warpPointers.address + (index - 1) * 2) + local warpTiles = unique(self:readTerminated( + warpPointers.bank, warpPointer, 0xFF)) + sorted(warpTiles) + + local base = spec.imageBase + if not written[base] then + local byteLength = spec.imageWidth * spec.imageHeight / 4 + local storedLength = blockPointer - gfxPointer + assert(storedLength >= 0 and storedLength <= byteLength + and storedLength % 16 == 0, + constName .. ": invalid stored tileset graphics length") + local pixels = self.rom:bytes(gfxBank, gfxPointer, storedLength) + while #pixels < byteLength do pixels[#pixels + 1] = 0 end + self:write2bpp(pixels, spec.imageWidth, spec.imageHeight, + "tilesets/" .. base .. ".png") + written[base] = true + end + + local counterTiles = {} + for _, value in ipairs(counters) do + if value ~= 0xFF then counterTiles[#counterTiles + 1] = value end + end + local grassTile + if grass ~= 0xFF then grassTile = grass end + out[constName] = { + id = constName, + source = ("ROM:Tilesets[%d]"):format(index - 1), + image = "assets/generated/tilesets/" .. base .. ".png", + imageWidth = spec.imageWidth, + imageHeight = spec.imageHeight, + tilesPerRow = spec.imageWidth / 8, + blocks = blocks, + walkable = walkable, + counterTiles = counterTiles, + grassTile = grassTile, + doorTiles = sorted(copy(doors[index - 1] or {})), + warpTiles = warpTiles, + animation = animations[animationId + 1], + } + self:tick("World tiles", index, #order + 4) + end + for number = 1, 3 do + local symbol = self:symbol("FlowerTile" .. number) + self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), + 8, 8, "tilesets/flower" .. number .. ".png") + self:tick("World tiles", #order + number, #order + 4) + end + local spinner = self:symbol("SpinnerArrowAnimTiles") + self:write2bpp(self.rom:bytes(spinner.bank, spinner.address, 64), + 32, 8, "tilesets/spinners.png") + self:write("tilesets", out) + self:tick("World tiles", #order + 4, #order + 4) + return out +end + +function RomExtractor:extractMaps() + self:beginStage("Maps") + local manifest = self.manifest + local mapOrder = manifest.constants.mapOrder + local dimensions = manifest.constants.maps + local metadata = manifest.maps + local tilesets = manifest.constants.tilesetOrder + local sprites = manifest.constants.spriteOrder + local movementNames = { [0xFE] = "WALK", [0xFF] = "STAY" } + local rangeNames = { + [0x00] = "ANY_DIR", [0x01] = "UP_DOWN", [0x02] = "LEFT_RIGHT", + [0x10] = "BOULDER_MOVEMENT_BYTE_2", [0xD0] = "DOWN", + [0xD1] = "UP", [0xD2] = "LEFT", [0xD3] = "RIGHT", [0xFF] = "NONE", + } + local directions = { + { "north", 0x08 }, { "south", 0x04 }, + { "west", 0x02 }, { "east", 0x01 }, + } + local function signed(value) return value >= 0x80 and value - 0x100 or value end + local function mapId(value) + if value == 0xFF then return "LAST_MAP" end + assert(value < #mapOrder, ("unknown map id $%02X"):format(value)) + return mapOrder[value + 1] + end + + local keys = {} + for key in pairs(metadata) do keys[#keys + 1] = key end + table.sort(keys) + local out = {} + for mapIndex, constName in ipairs(keys) do + local spec, dims = metadata[constName], dimensions[constName] + local label = spec.label + local header = self:symbol(label .. "_h") + local address = header.address + local tilesetId = self.rom:byte(header.bank, address) + local height = self.rom:byte(header.bank, address + 1) + local width = self.rom:byte(header.bank, address + 2) + assert(width == dims.width and height == dims.height, + constName .. ": ROM dimensions do not match metadata") + assert(tilesetId < #tilesets, constName .. ": unknown tileset id") + local blockPointer = self.rom:word(header.bank, address + 3) + local connectionFlags = self.rom:byte(header.bank, address + 9) + address = address + 10 + + local connections = {} + for _, directionSpec in ipairs(directions) do + local direction, flag = directionSpec[1], directionSpec[2] + if bit.band(connectionFlags, flag) ~= 0 then + local targetId = self.rom:byte(header.bank, address) + local yOffset = signed(self.rom:byte(header.bank, address + 7)) + local xOffset = signed(self.rom:byte(header.bank, address + 8)) + local encoded = (direction == "north" or direction == "south") + and xOffset or yOffset + assert(encoded % 2 == 0, constName .. ": odd connection offset") + connections[direction] = { + map = mapId(targetId), + offset = -encoded / 2, + } + address = address + 11 + end + end + assert(bit.band(connectionFlags, 0xF0) == 0, + constName .. ": unknown connection flags") + local objectPointer = self.rom:word(header.bank, address) + local objectAddress = objectPointer + local borderBlock = self.rom:byte(header.bank, objectAddress) + objectAddress = objectAddress + 1 + + local warpCount = self.rom:byte(header.bank, objectAddress) + objectAddress = objectAddress + 1 + local warps = {} + for _ = 1, warpCount do + local row = self.rom:bytes(header.bank, objectAddress, 4) + warps[#warps + 1] = { + x = row[2], y = row[1], + destMap = mapId(row[4]), destWarp = row[3] + 1, + } + objectAddress = objectAddress + 4 + end + + local signCount = self.rom:byte(header.bank, objectAddress) + objectAddress = objectAddress + 1 + assert(signCount == #spec.signTexts, constName .. ": sign count mismatch") + local signs = {} + for _, signText in ipairs(spec.signTexts) do + local row = self.rom:bytes(header.bank, objectAddress, 3) + signs[#signs + 1] = { x = row[2], y = row[1], text = signText } + objectAddress = objectAddress + 3 + end + + local objectCount = self.rom:byte(header.bank, objectAddress) + objectAddress = objectAddress + 1 + assert(objectCount == #spec.objects, constName .. ": object count mismatch") + local objects = {} + for index, objectSpec in ipairs(spec.objects) do + local row = self.rom:bytes(header.bank, objectAddress, 6) + local spriteId, y, x = row[1], row[2], row[3] + local movementId, rangeId, textId = row[4], row[5], row[6] + assert(spriteId >= 1 and spriteId <= #sprites, + constName .. ": unknown object sprite") + assert(movementNames[movementId] and rangeNames[rangeId], + constName .. ": unknown movement encoding") + local object = { + index = index, x = x - 4, y = y - 4, + sprite = sprites[spriteId], + movement = movementNames[movementId], + range = rangeNames[rangeId], + text = objectSpec.text, + } + objectAddress = objectAddress + 6 + if bit.band(textId, 0x80) ~= 0 then + assert(objectSpec.item, constName .. ": unexpected item payload") + object.item = objectSpec.item + objectAddress = objectAddress + 1 + elseif bit.band(textId, 0x40) ~= 0 then + local extra = self.rom:bytes(header.bank, objectAddress, 2) + objectAddress = objectAddress + 2 + if objectSpec.trainerClass then + object.trainerClass = objectSpec.trainerClass + object.trainerParty = type(objectSpec.trainerParty) == "string" + and objectSpec.trainerParty or extra[2] + elseif objectSpec.pokemon then + object.pokemon = objectSpec.pokemon + object.level = extra[2] + else + error(constName .. ": unexpected trainer or Pokemon payload") + end + else + assert(not objectSpec.item and not objectSpec.trainerClass + and not objectSpec.pokemon, constName .. ": missing extra payload") + end + if objectSpec.name then object.name = objectSpec.name end + if objectSpec.hidden ~= nil then object.hidden = objectSpec.hidden end + objects[#objects + 1] = object + end + + local expectedBlocks = width * height + assert(spec.blockLength <= expectedBlocks, + constName .. ": block payload exceeds map dimensions") + local blocks = self.rom:bytes( + header.bank, blockPointer, spec.blockLength) + while #blocks < expectedBlocks do blocks[#blocks + 1] = borderBlock end + + out[constName] = { + id = constName, label = label, index = dims.index, + source = ("ROM:%02X:%04X"):format(header.bank, header.address), + tileset = tilesets[tilesetId + 1], + width = width, height = height, blocks = blocks, + borderBlock = borderBlock, connections = connections, + warps = warps, signs = signs, objects = objects, + } + self:tick("Maps", mapIndex, #keys) + end + self:write("maps", out) + return out +end + +function RomExtractor:extractFont() + self:beginStage("Fonts") + local mainSymbol = self:symbol("FontGraphics") + local raw = self.rom:bytes(mainSymbol.bank, mainSymbol.address, 128 * 8) + local image = ImageWriter.blank(128, 64, 0, 0, 0, 0) + for tile = 0, 127 do + local tileX, tileY = tile % 16 * 8, math.floor(tile / 16) * 8 + for y = 0, 7 do + local row = raw[tile * 8 + y + 1] + for x = 0, 7 do + if bit.band(row, 2 ^ (7 - x)) ~= 0 then + image:setPixel(tileX + x, tileY + y, 0, 0, 0, 1) + end + end + end + end + self:save(image, "fonts/font.png") + self:tick("Fonts", 1, 2) + + local extraSymbol = self:symbol("TextBoxGraphics") + local shaded = ImageWriter.decode2bpp( + self.rom:bytes(extraSymbol.bank, extraSymbol.address, 32 * 16), + 128, 16) + local extra = ImageWriter.blank(128, 16, 0, 0, 0, 0) + for y = 0, 15 do + for x = 0, 127 do + local r = shaded:getPixel(x, y) + if r < 0.5 then extra:setPixel(x, y, 0, 0, 0, 1) end + end + end + local pokedex = self:symbol("PokedexTileGraphics") + local dex = ImageWriter.decode2bpp( + self.rom:bytes(pokedex.bank, pokedex.address, 32), 16, 8) + for y = 0, 7 do + for x = 0, 15 do + local r = dex:getPixel(x, y) + extra:setPixel(x, y, 0, 0, 0, r < 0.5 and 1 or 0) + end + end + self:save(extra, "fonts/font_extra.png") + local data = { + source = "ROM:FontGraphics, TextBoxGraphics, PokedexTileGraphics", + image = "assets/generated/fonts/font.png", + imageExtra = "assets/generated/fonts/font_extra.png", + mainBase = 0x80, extraBase = 0x60, glyphsPerRow = 16, + charmap = self.manifest.fontCharmap, + } + self:write("font", data) + self:tick("Fonts", 2, 2) + return data +end + +function RomExtractor:extractSprites() + self:beginStage("Overworld sprites") + local order = self.manifest.constants.spriteOrder + local metadata = self.manifest.sprites.order + local pointerTable = self:symbol("SpriteSheetPointerTable") + assert(#metadata == #order, "sprite metadata count does not match constants") + local out, written = {}, {} + for index, constName in ipairs(order) do + local spec = metadata[index] + assert(spec.id == constName, "sprite metadata is out of order") + local address = pointerTable.address + (index - 1) * 4 + local pointer = self.rom:word(pointerTable.bank, address) + local firstHalf = self.rom:byte(pointerTable.bank, address + 2) + local bank = self.rom:byte(pointerTable.bank, address + 3) + local byteLength = spec.imageWidth * spec.imageHeight / 4 + local frames = spec.imageHeight / 16 + local expected = firstHalf * (frames >= 6 and 2 or 1) + assert(byteLength == expected, constName .. ": sprite length mismatch") + local base = spec.imageBase + if not written[base] then + self:write2bpp(self.rom:bytes(bank, pointer, byteLength), + spec.imageWidth, spec.imageHeight, + "sprites/" .. base .. ".png", true) + written[base] = true + end + out[constName] = { + id = constName, + source = ("ROM:SpriteSheetPointerTable[%d]"):format(index - 1), + image = "assets/generated/sprites/" .. base .. ".png", + frames = frames, walker = frames >= 6, + } + self:tick("Overworld sprites", index, #order + 1) + end + local bike = self.manifest.sprites.bike + local bikeSymbol = self:symbol(bike.label) + self:write2bpp( + self.rom:bytes(bikeSymbol.bank, bikeSymbol.address, + bike.imageWidth * bike.imageHeight / 4), + bike.imageWidth, bike.imageHeight, + "sprites/" .. bike.imageBase .. ".png", true) + local bikeFrames = bike.imageHeight / 16 + out.SPRITE_RED_BIKE = { + id = "SPRITE_RED_BIKE", source = "ROM:RedBikeSprite", + image = "assets/generated/sprites/red_bike.png", + frames = bikeFrames, walker = bikeFrames >= 6, + } + self:write("sprites", out) + self:tick("Overworld sprites", #order + 1, #order + 1) + return out +end + +function RomExtractor:animationFlags(count) + local pointerTable = self:symbol("AttackAnimationPointers") + local flags = {} + for index = 0, count - 1 do + local address = self.rom:word( + pointerTable.bank, pointerTable.address + index * 2) + local shake, flash, ended = false, false, false + for _ = 1, 256 do + local first = self.rom:byte(pointerTable.bank, address) + if first == 0xFF then ended = true; break end + if first >= 0xD8 then + shake = shake or first == 0xFB + flash = flash or first == 0xF8 or first == 0xFE + address = address + 2 + else + address = address + 3 + end + end + assert(ended, "unterminated move animation " .. (index + 1)) + flags[#flags + 1] = { shake, flash } + end + return flags +end + +function RomExtractor:extractMoves() + self:beginStage("Moves") + local order = self.manifest.constants.moveOrder + local types = {} + for name, value in pairs(self.manifest.constants.types) do types[value] = name end + local effects = self.manifest.moveEffects + local charmap = self.manifest.charmap + local moves = self:symbol("Moves") + local names = self:symbol("MoveNames") + local sounds = self:symbol("MoveSoundTable") + local flags = self:animationFlags(#order) + local decodedNames, address = {}, names.address + for _ = 1, #order do + local value, consumed = self.rom:readString( + names.bank, address, charmap, 0x50, 32) + decodedNames[#decodedNames + 1] = value + address = address + consumed + end + local out = {} + for index, moveId in ipairs(order) do + local row = self.rom:bytes(moves.bank, moves.address + (index - 1) * 6, 6) + assert(row[1] == index, "Moves row stores wrong animation id") + local effect = effects[row[2] + 1] or hex("EFFECT", row[2]) + local typeName = types[row[4]] or hex("TYPE", row[4]) + local soundId, pitch, tempo = unpack(self.rom:bytes( + sounds.bank, sounds.address + (index - 1) * 3, 3)) + local animation = { + sound = self.manifest.sfxKeys[tostring(soundId)] or hex("SFX", soundId), + pitch = pitch, tempo = tempo, + } + if flags[index][1] then animation.shake = true end + if flags[index][2] then animation.flash = true end + out[moveId] = { + id = moveId, index = index, name = decodedNames[index], + source = ("ROM:Moves[%d]"):format(index), + effect = effect, power = row[3], type = typeName, + accuracy = round(row[5] * 100 / 255), pp = row[6], + anim = animation, + } + self:tick("Moves", index, #order) + end + self:write("moves", out) + return out +end + +function RomExtractor:extractBattleAnimations() + self:beginStage("Battle animations") + local metadata = self.manifest.battleAnimations + local moveOrder = self.manifest.constants.moveOrder + assert(#moveOrder == metadata.moveCount, + "battle animation move count does not match constants") + local total = metadata.baseCoordCount + metadata.frameBlockCount + + metadata.subanimCount + metadata.moveCount + + #metadata.miscAnimations + 3 + local completed = 0 + local function tick() + completed = completed + 1 + self:tick("Battle animations", completed, total) + end + + local coordsSymbol = self:symbol("FrameBlockBaseCoords") + local baseCoords = {} + for index = 0, metadata.baseCoordCount - 1 do + local row = self.rom:bytes( + coordsSymbol.bank, coordsSymbol.address + index * 2, 2) + baseCoords[index] = { y = row[1], x = row[2] } + tick() + end + + local blocksSymbol = self:symbol("FrameBlockPointers") + local frameBlocks = {} + for index = 0, metadata.frameBlockCount - 1 do + local address = self.rom:word( + blocksSymbol.bank, blocksSymbol.address + index * 2) + local count = self.rom:byte(blocksSymbol.bank, address) + address = address + 1 + local entries = {} + for _ = 1, count do + local row = self.rom:bytes(blocksSymbol.bank, address, 4) + local attrs = row[4] + local entry = { + y = row[1], x = row[2], tile = row[3], + xflip = bit.band(attrs, 0x20) ~= 0, + yflip = bit.band(attrs, 0x40) ~= 0, + } + if bit.band(attrs, 0x80) ~= 0 then entry.prio = true end + if bit.band(attrs, 0x10) ~= 0 then entry.pal1 = true end + entries[#entries + 1] = entry + address = address + 4 + end + frameBlocks[index] = entries + tick() + end + + local subanimSymbol = self:symbol("SubanimationPointers") + local subanims = {} + for index = 0, metadata.subanimCount - 1 do + local address = self.rom:word( + subanimSymbol.bank, subanimSymbol.address + index * 2) + local packed = self.rom:byte(subanimSymbol.bank, address) + local typeId = math.floor(packed / 0x20) + local count = packed % 0x20 + local typeName = metadata.subanimTypes[typeId + 1] + assert(typeName, "subanimation " .. index .. " has unknown type") + address = address + 1 + local entries = {} + for _ = 1, count do + local row = self.rom:bytes(subanimSymbol.bank, address, 3) + assert(row[1] < metadata.frameBlockCount, + "subanimation " .. index .. " has invalid frame block") + assert(row[2] < metadata.baseCoordCount, + "subanimation " .. index .. " has invalid base coord") + entries[#entries + 1] = { + block = row[1], coord = row[2], mode = row[3], + } + address = address + 3 + end + subanims[index] = { type = typeName, blocks = entries } + tick() + end + + local tilesTable = self:symbol("MoveAnimationTilesPointers") + assert(#metadata.tilesheets == 3, + "expected three battle animation tilesheets") + local tileRows = {} + for index = 0, 2 do + local row = self.rom:bytes( + tilesTable.bank, tilesTable.address + index * 4, 4) + assert(row[4] == 0xFF, + "battle animation tilesheet " .. index .. " has invalid padding") + local pointer = row[2] + row[3] * 0x100 + local expected = self:symbol("MoveAnimationTiles" .. index) + assert(expected.bank == tilesTable.bank and expected.address == pointer, + "battle animation tilesheet " .. index .. " pointer differs") + tileRows[index] = { + count = row[1], pointer = pointer, + spec = metadata.tilesheets[index + 1], + } + end + + local imagePayloads = {} + for index = 0, 2 do + local row = tileRows[index] + local path = row.spec.path + local payload = imagePayloads[path] + if payload then + assert(payload.pointer == row.pointer, + "shared battle animation atlas has two pointers") + else + payload = { pointer = row.pointer, tiles = 0, spec = row.spec } + imagePayloads[path] = payload + end + payload.tiles = math.max(payload.tiles, row.count) + end + local prefix = "assets/generated/" + for path, payload in pairs(imagePayloads) do + local spec = payload.spec + local byteLength = spec.width * spec.height / 4 + local storedLength = payload.tiles * 16 + assert(storedLength <= byteLength, + path .. ": battle animation atlas is too large") + local raw = self.rom:bytes( + tilesTable.bank, payload.pointer, storedLength) + while #raw < byteLength do raw[#raw + 1] = 0 end + assert(startsWith(path, prefix), "invalid generated asset path") + self:write2bpp(raw, spec.width, spec.height, + path:sub(#prefix + 1), true) + end + + local tilesheets = {} + for index = 0, 2 do + local row, spec = tileRows[index], tileRows[index].spec + tilesheets[index] = { + path = spec.path, width = spec.width, height = spec.height, + tiles = row.count, source = spec.source, + } + tick() + end + + local moveNames = copy(moveOrder) + append(moveNames, metadata.miscAnimations) + local pointerTable = self:symbol("AttackAnimationPointers") + local moveAnims = {} + for index, name in ipairs(moveNames) do + local address = self.rom:word( + pointerTable.bank, pointerTable.address + (index - 1) * 2) + local sequence, ended = {}, false + for _ = 1, 256 do + local first = self.rom:byte(pointerTable.bank, address) + if first == 0xFF then ended = true; break end + local sound = self.rom:byte(pointerTable.bank, address + 1) + local row + if first >= metadata.firstSpecialEffect then + local effect = metadata.specialEffects[tostring(first)] + assert(effect, name .. ": unknown special effect") + row = { effect = effect } + address = address + 2 + else + local subanim = self.rom:byte(pointerTable.bank, address + 2) + local delay = first % 0x40 + local tileset = math.floor(first / 0x40) + assert(delay > 0, name .. ": zero animation delay") + assert(subanim < metadata.subanimCount, + name .. ": unknown subanimation") + assert(tilesheets[tileset], + name .. ": unknown animation tileset") + row = { subanim = subanim, tileset = tileset, delay = delay } + address = address + 3 + end + if sound ~= 0xFF then + assert(sound < #moveOrder, name .. ": unknown animation sound") + row.sound = moveOrder[sound + 1] + end + sequence[#sequence + 1] = row + end + assert(ended, name .. ": unterminated battle animation") + moveAnims[name] = { + source = ("ROM:AttackAnimationPointers[%d]"):format(index - 1), + seq = sequence, + } + tick() + end + + for name, anim in pairs(moveAnims) do + for _, row in ipairs(anim.seq) do + if row.subanim then + local sheet = tilesheets[row.tileset] + for _, blockRef in ipairs(subanims[row.subanim].blocks) do + for _, tile in ipairs(frameBlocks[blockRef.block]) do + assert(tile.tile < sheet.tiles, + name .. ": animation tile is out of range") + end + end + end + end + end + + local out = { + tilesheets = tilesheets, + baseCoords = baseCoords, + frameBlocks = frameBlocks, + subanims = subanims, + moveAnims = moveAnims, + } + self:write("battle_anims", out) + return out +end + +function RomExtractor:nybbles(raw, count) + local out = {} + for _, value in ipairs(raw) do + out[#out + 1], out[#out + 2] = math.floor(value / 16), value % 16 + end + while #out > count do table.remove(out) end + return out +end + +function RomExtractor:extractItems() + self:beginStage("Items") + local order = self.manifest.items + local charmap = self.manifest.charmap + local names = self:symbol("ItemNames") + local prices = self:symbol("ItemPrices") + local keyFlags = self:symbol("KeyItemFlags") + local tmPrices = self:symbol("TechnicalMachinePrices") + local decodedNames, address = {}, names.address + for _ = 1, #order do + local value, consumed = self.rom:readString( + names.bank, address, charmap, 0x50, 32) + decodedNames[#decodedNames + 1] = value + address = address + consumed + end + local numItems = self.manifest.numItems + local flags = self.rom:bytes( + keyFlags.bank, keyFlags.address, math.floor((numItems + 7) / 8)) + local out = {} + for index, itemId in ipairs(order) do + local entry = { + id = itemId, index = index, name = decodedNames[index], + price = Rom.bcd(self.rom:bytes( + prices.bank, prices.address + (index - 1) * 3, 3)), + source = ("ROM:ItemNames[%d]"):format(index), + } + if index <= numItems + and bit.band(flags[math.floor((index - 1) / 8) + 1], + 2 ^ ((index - 1) % 8)) ~= 0 then + entry.keyItem = true + end + out[itemId] = entry + end + for number, move in ipairs(self.manifest.hms) do + local itemId = "HM_" .. move + out[itemId] = { + id = itemId, name = ("HM%02d"):format(number), price = 0, + machine = { kind = "HM", number = number, move = move }, + source = "ROM metadata manifest (HM mapping)", + } + end + local packed = self.rom:bytes(tmPrices.bank, tmPrices.address, + math.floor((#self.manifest.tms + 1) / 2)) + local pricesByTm = self:nybbles(packed, #self.manifest.tms) + for number, move in ipairs(self.manifest.tms) do + local itemId = "TM_" .. move + out[itemId] = { + id = itemId, name = ("TM%02d"):format(number), + price = pricesByTm[number] * 1000, + machine = { kind = "TM", number = number, move = move }, + source = ("ROM:TechnicalMachinePrices[%d]"):format(number), + } + end + self:write("items", out) + self:tick("Items", 1, 1) + return out +end + +function RomExtractor:extractTypeChart() + self:beginStage("Types") + local types = {} + for name, value in pairs(self.manifest.constants.types) do types[value] = name end + local effects = self:symbol("TypeEffects") + local address, matchups = effects.address, {} + while self.rom:byte(effects.bank, address) ~= 0xFF do + local row = self.rom:bytes(effects.bank, address, 3) + matchups[#matchups + 1] = { + attacker = types[row[1]] or hex("TYPE", row[1]), + defender = types[row[2]] or hex("TYPE", row[2]), + multiplier = row[3], + } + address = address + 3 + end + local names, seen = {}, {} + for _, label in ipairs(self.manifest.typeNameLabels) do + local symbol = self:symbol(label) + local location = symbol.bank .. ":" .. symbol.address + if not seen[location] then + seen[location] = true + names[#names + 1] = self.rom:readString( + symbol.bank, symbol.address, self.manifest.charmap, 0x50, 16) + end + end + local data = { + source = "ROM:TypeEffects + TypeNames", + matchups = matchups, names = names, + } + self:write("type_chart", data) + self:tick("Types", 1, 1) + return data +end + +function RomExtractor:extractPalettes() + self:beginStage("Color palettes") + local order = self.manifest.paletteOrder + local paletteTable = self:symbol("SuperPalettes") + local function scale5(value) return round(value * 255 / 31) end + local palettes = {} + for index, name in ipairs(order) do + local colors = {} + for color = 0, 3 do + local value = self.rom:word(paletteTable.bank, + paletteTable.address + (index - 1) * 8 + color * 2) + colors[#colors + 1] = { + scale5(bit.band(value, 0x1F)), + scale5(bit.band(bit.rshift(value, 5), 0x1F)), + scale5(bit.band(bit.rshift(value, 10), 0x1F)), + } + end + palettes[name] = colors + end + local monsterTable = self:symbol("MonsterPalettes") + local monsterPalettes = {} + for index, species in ipairs(self.manifest.dexOrder) do + local paletteId = self.rom:byte( + monsterTable.bank, monsterTable.address + index) + monsterPalettes[species] = order[paletteId + 1] + end + local data = { + source = "ROM:SuperPalettes + MonsterPalettes", + palettes = palettes, order = order, pokemon = monsterPalettes, + } + self:write("palettes", data) + self:tick("Color palettes", 1, 1) + return data +end + +function RomExtractor:extractIcons() + self:beginStage("Party icons") + local iconTable = self:symbol("MonPartyData") + local count = #self.manifest.dexOrder + local packed = self.rom:bytes(iconTable.bank, iconTable.address, + math.floor((count + 1) / 2)) + local values = self:nybbles(packed, count) + local byDex = {} + for _, value in ipairs(values) do + byDex[#byDex + 1] = self.manifest.iconOrder[value + 1] + or ("ICON_%X"):format(value) + end + local icons = { + MON = "assets/generated/sprites/monster.png", + BALL = "assets/generated/sprites/poke_ball.png", + HELIX = "assets/generated/sprites/fossil.png", + FAIRY = "assets/generated/sprites/fairy.png", + BIRD = "assets/generated/sprites/bird.png", + WATER = "assets/generated/sprites/seel.png", + BUG = "assets/generated/icons/bug.png", + GRASS = "assets/generated/icons/plant.png", + SNAKE = "assets/generated/icons/snake.png", + QUADRUPED = "assets/generated/icons/quadruped.png", + } + local frames = { + { "bug", "BugIconFrame1", "BugIconFrame2" }, + { "plant", "PlantIconFrame1", "PlantIconFrame2" }, + { "snake", "SnakeIconFrame1", "SnakeIconFrame2" }, + { "quadruped", "QuadrupedIconFrame1", "QuadrupedIconFrame2" }, + } + for index, spec in ipairs(frames) do + local raw = {} + for labelIndex = 2, 3 do + local symbol = self:symbol(spec[labelIndex]) + append(raw, self.rom:bytes(symbol.bank, symbol.address, 32)) + end + local half = ImageWriter.decode2bpp(raw, 8, 32, true) + local image = ImageWriter.blank(16, 32, 1, 1, 1, 0) + for frame = 0, 1 do + ImageWriter.blit(image, half, 0, frame * 16, 0, frame * 16, 8, 16) + ImageWriter.blit(image, half, 8, frame * 16, 0, frame * 16, 8, 16, true) + end + self:save(image, "icons/" .. spec[1] .. ".png") + self:tick("Party icons", index, #frames) + end + local data = { source = "ROM:MonPartyData", byDex = byDex, icons = icons } + self:write("icons", data) + return data +end + +function RomExtractor:species(value) + local order = self.manifest.constants.speciesOrder + if value < 1 or value > #order then return hex("SPECIES", value) end + return order[value] +end + +function RomExtractor:item(value) + local order = self.manifest.items + if value < 1 or value > #order then return hex("ITEM", value) end + return order[value] +end + +function RomExtractor:move(value) + if value == 0 then return nil end + local order = self.manifest.constants.moveOrder + if value < 1 or value > #order then return hex("MOVE", value) end + return order[value] +end + +function RomExtractor:typesById() + local result = {} + for name, value in pairs(self.manifest.constants.types) do + result[value] = name + end + return result +end + +function RomExtractor:decodeEvolutionsAndMoves(index) + local pointerTable = self:symbol("EvosMovesPointerTable") + local address = self.rom:word( + pointerTable.bank, pointerTable.address + (index - 1) * 2) + local evolutions = {} + while true do + local method = self.rom:byte(pointerTable.bank, address) + address = address + 1 + if method == 0 then break end + if method == 1 then + local row = self.rom:bytes(pointerTable.bank, address, 2) + address = address + 2 + evolutions[#evolutions + 1] = { + method = "LEVEL", level = row[1], species = self:species(row[2]), + } + elseif method == 2 then + local row = self.rom:bytes(pointerTable.bank, address, 3) + address = address + 3 + evolutions[#evolutions + 1] = { + method = "ITEM", item = self:item(row[1]), level = row[2], + species = self:species(row[3]), + } + elseif method == 3 then + local row = self.rom:bytes(pointerTable.bank, address, 2) + address = address + 2 + evolutions[#evolutions + 1] = { + method = "TRADE", level = row[1], species = self:species(row[2]), + } + else + error(("unknown evolution method %d for species index %d") + :format(method, index)) + end + end + + local learnset = {} + while true do + local level = self.rom:byte(pointerTable.bank, address) + address = address + 1 + if level == 0 then break end + local move = self.rom:byte(pointerTable.bank, address) + address = address + 1 + learnset[#learnset + 1] = { level = level, move = self:move(move) } + end + return evolutions, learnset +end + +function RomExtractor:dexEntry(index, species) + local pointerTable = self:symbol("PokedexEntryPointers") + local address = self.rom:word( + pointerTable.bank, pointerTable.address + (index - 1) * 2) + local kind, consumed = self.rom:readString( + pointerTable.bank, address, self.manifest.charmap, 0x50, 32) + address = address + consumed + local heightFt = self.rom:byte(pointerTable.bank, address) + local heightIn = self.rom:byte(pointerTable.bank, address + 1) + local weight = self.rom:word(pointerTable.bank, address + 2) + address = address + 4 + assert(self.rom:byte(pointerTable.bank, address) == 0x17, + "dex entry " .. index .. " has no TX_FAR command") + local textAddress = self.rom:word(pointerTable.bank, address + 1) + local textBank = self.rom:byte(pointerTable.bank, address + 3) + local textLabel = self.manifest.dexEntryLabels[species] + or ("_DexEntry_%02X_%04X"):format(textBank, textAddress) + return { + kind = kind, heightFt = heightFt, heightIn = heightIn, + weight = weight, text = textLabel, + } +end + +function RomExtractor:extractPokemon() + self:beginStage("Pokemon") + local speciesOrder = self.manifest.constants.speciesOrder + local dexBySpecies = {} + for index, species in ipairs(self.manifest.dexOrder) do + dexBySpecies[species] = index + end + local typeById = self:typesById() + local names = self:symbol("MonsterNames") + local baseStats = self:symbol("BaseStats") + local mewStats = self:symbol("MewBaseStats") + local decodedNames = {} + for index = 1, #speciesOrder do + decodedNames[index] = self.rom:decodeText( + self.rom:bytes(names.bank, names.address + (index - 1) * 10, 10), + self.manifest.charmap) + end + + local out, writtenFront, writtenBack = {}, {}, {} + local completed = 0 + for index, species in ipairs(speciesOrder) do + local skip = startsWith(species, "MISSINGNO") + or startsWith(species, "UNUSED") + or startsWith(species, "FOSSIL_") + or startsWith(species, "MON_GHOST") + if not skip then + local dex = assert(dexBySpecies[species], + "missing dex number for " .. species) + local row + if species == "MEW" then + row = self.rom:bytes(mewStats.bank, mewStats.address, 28) + else + row = self.rom:bytes( + baseStats.bank, baseStats.address + (dex - 1) * 28, 28) + end + assert(row[1] == dex, species .. ": base stats dex mismatch") + + local level1Moves = {} + for position = 16, 19 do + if row[position] ~= 0 then + level1Moves[#level1Moves + 1] = self:move(row[position]) + end + end + local tmhm = {} + for moveIndex, move in ipairs(self.manifest.tmhmMoves) do + local byte = row[21 + math.floor((moveIndex - 1) / 8)] + if bit.band(byte, 2 ^ ((moveIndex - 1) % 8)) ~= 0 then + tmhm[#tmhm + 1] = move + end + end + local evolutions, learnset = self:decodeEvolutionsAndMoves(index) + local asset = self.manifest.pokemonAssets[species] + local front, back = asset.front, asset.back + if front and not writtenFront[front] then + local size = self:writeCompressedPic( + asset.frontLabel, "battle/front/" .. front .. ".png") + assert(size == math.floor(row[11] / 16), + species .. ": front picture size mismatch") + writtenFront[front] = true + end + if back and not writtenBack[back] then + self:writeCompressedPic( + asset.backLabel, "battle/back/" .. back .. ".png") + writtenBack[back] = true + end + local speciesTypes = unique({ + typeById[row[7]] or hex("TYPE", row[7]), + typeById[row[8]] or hex("TYPE", row[8]), + }) + out[species] = { + id = species, index = index, dex = dex, + name = decodedNames[index], + source = ("ROM:BaseStats[%d]"):format(dex), + types = speciesTypes, + baseStats = { + hp = row[2], attack = row[3], defense = row[4], + speed = row[5], special = row[6], + }, + catchRate = row[9], baseExp = row[10], + level1Moves = level1Moves, + growthRate = self.manifest.growthRates[row[20] + 1], + tmhm = tmhm, learnset = learnset, evolutions = evolutions, + spriteFront = front + and "assets/generated/battle/front/" .. front .. ".png" or nil, + spriteBack = back + and "assets/generated/battle/back/" .. back .. ".png" or nil, + frontSize = math.floor(row[11] / 16), + dexEntry = self:dexEntry(index, species), + } + completed = completed + 1 + self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) + end + end + + for _, spec in ipairs({ + { "FossilAerodactylPic", "fossilaerodactyl" }, + { "FossilKabutopsPic", "fossilkabutops" }, + { "GhostPic", "ghost" }, + }) do + self:writeCompressedPic(spec[1], "battle/front/" .. spec[2] .. ".png") + completed = completed + 1 + self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) + end + for _, spec in ipairs({ + { "RedPicBack", "redb" }, { "OldManPicBack", "oldmanb" }, + }) do + self:writeCompressedPic(spec[1], "battle/" .. spec[2] .. ".png") + completed = completed + 1 + self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) + end + local balls = self:symbol("PokeballTileGraphics") + self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64), + 32, 8, "battle/balls.png", true) + completed = completed + 1 + self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) + + for _, spec in ipairs({ + { "TrainerInfoTextBoxTileGraphics", "trainer_info.png", 24, 24, false }, + { "GymLeaderFaceAndBadgeTileGraphics", "badges.png", 16, 256, true }, + { "BadgeNumbersTileGraphics", "badge_numbers.png", 16, 32, true }, + { "CircleTile", "circle_tile.png", 8, 8, true }, + }) do + local symbol = self:symbol(spec[1]) + self:write2bpp( + self.rom:bytes(symbol.bank, symbol.address, spec[3] * spec[4] / 4), + spec[3], spec[4], "trainer_card/" .. spec[2], spec[5]) + completed = completed + 1 + self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) + end + self:writeCompressedPic("RedPicFront", "trainer_card/red.png") + self:write("pokemon", out) + self:tick("Pokemon", #self.manifest.dexOrder + 10, + #self.manifest.dexOrder + 10) + return out +end + +function RomExtractor:trainerParties(bank, startAddress, endAddress) + local parties, address = {}, startAddress + while address < endAddress do + local first = self.rom:byte(bank, address) + address = address + 1 + local party = {} + if first == 0xFF then + while true do + local level = self.rom:byte(bank, address) + address = address + 1 + if level == 0 then break end + local species = self.rom:byte(bank, address) + address = address + 1 + party[#party + 1] = { + level = level, species = self:species(species), + } + end + else + while true do + local species = self.rom:byte(bank, address) + address = address + 1 + if species == 0 then break end + party[#party + 1] = { + level = first, species = self:species(species), + } + end + end + parties[#parties + 1] = party + end + assert(address == endAddress, + ("trainer party data overran %02X:%04X"):format(bank, endAddress)) + return parties +end + +function RomExtractor:extractTrainers() + self:beginStage("Trainers") + local order = self.manifest.trainers + local names = self:symbol("TrainerNames") + local pointers = self:symbol("TrainerDataPointers") + local money = self:symbol("TrainerPicAndMoneyPointers") + local choices = self:symbol("TrainerClassMoveChoiceModifications") + local decodedNames, address = {}, names.address + for _ = 1, #order do + local name, consumed = self.rom:readString( + names.bank, address, self.manifest.charmap, 0x50, 32) + decodedNames[#decodedNames + 1] = name + address = address + consumed + end + + local aiMods = {} + address = choices.address + for _ = 1, #order do + local mods = {} + while true do + local value = self.rom:byte(choices.bank, address) + address = address + 1 + if value == 0 then break end + mods[#mods + 1] = value + end + aiMods[#aiMods + 1] = mods + end + local partyStarts = {} + for index = 0, #order - 1 do + partyStarts[#partyStarts + 1] = self.rom:word( + pointers.bank, pointers.address + index * 2) + end + local partyEnds = {} + for index = 2, #partyStarts do partyEnds[#partyEnds + 1] = partyStarts[index] end + partyEnds[#partyEnds + 1] = self:symbol("TrainerAI").address + + local out, written = {}, {} + for index, label in ipairs(order) do + local trainerId = "OPP_" .. label + local rawMoney = self.rom:bytes( + money.bank, money.address + (index - 1) * 5 + 2, 3) + local picture = self.manifest.trainerPics[index] + if picture and not written[picture.imageBase] then + self:writeCompressedPic(picture.label, + "battle/trainers/" .. picture.imageBase .. ".png") + written[picture.imageBase] = true + end + out[trainerId] = { + id = trainerId, index = index, name = decodedNames[index], + source = "ROM:TrainerDataPointers", + pic = picture and picture.path or nil, + baseMoney = math.floor(Rom.bcd(rawMoney) / 100), + aiMods = aiMods[index], + parties = self:trainerParties( + pointers.bank, partyStarts[index], partyEnds[index]), + } + self:tick("Trainers", index, #order) + end + self:write("trainers", out) + return out +end + +function RomExtractor:wildTable(bank, address) + local grassRate = self.rom:byte(bank, address) + address = address + 1 + local grass = { rate = grassRate, slots = {} } + if grassRate ~= 0 then + for _ = 1, 10 do + local row = self.rom:bytes(bank, address, 2) + address = address + 2 + grass.slots[#grass.slots + 1] = { + level = row[1], species = self:species(row[2]), + } + end + end + local waterRate = self.rom:byte(bank, address) + address = address + 1 + local water = { rate = waterRate, slots = {} } + if waterRate ~= 0 then + for _ = 1, 10 do + local row = self.rom:bytes(bank, address, 2) + address = address + 2 + water.slots[#water.slots + 1] = { + level = row[1], species = self:species(row[2]), + } + end + end + return grass, water +end + +function RomExtractor:extractEncounters() + self:beginStage("Wild Pokemon") + local maps = self.manifest.constants.mapOrder + local pointers = self:symbol("WildDataPointers") + local nothing = self:symbol("NothingWildMons") + local out = {} + for index, mapId in ipairs(maps) do + local address = self.rom:word( + pointers.bank, pointers.address + (index - 1) * 2) + if address ~= nothing.address then + local grass, water = self:wildTable(pointers.bank, address) + local entry = { + source = ("ROM:%02X:%04X"):format(pointers.bank, address), + } + if grass.rate ~= 0 or #grass.slots > 0 then entry.grass = grass end + if water.rate ~= 0 or #water.slots > 0 then entry.water = water end + out[mapId] = entry + end + self:tick("Wild Pokemon", index, #maps) + end + self:write("encounters", out) + return out +end + +local TEXT_GLYPH_OVERRIDES = { + [0x4B] = "{_CONT}", [0x4C] = "{SCROLL}", + [0x6D] = "{COLON}", [0xF0] = "¥", +} + +function RomExtractor:textGlyph(value) + if TEXT_GLYPH_OVERRIDES[value] then return TEXT_GLYPH_OVERRIDES[value] end + local glyph = self.manifest.charmap[tostring(value)] + or ("{BYTE:%02X}"):format(value) + if glyph:sub(1, 1) == "<" and glyph:sub(-1) == ">" then + return "{" .. glyph:sub(2, -2) .. "}" + end + return glyph +end + +function RomExtractor:decodeTextCommands(symbol, substitutions) + local address = symbol.address + local pending = 1 + local out = {} + for _ = 1, 4096 do + local command = self.rom:byte(symbol.bank, address) + address = address + 1 + if command == 0x50 then + assert(pending > #substitutions, + symbol.name .. ": unused dynamic text substitutions") + return table.concat(out) + elseif command == 0 then + while true do + local value = self.rom:byte(symbol.bank, address) + address = address + 1 + if value == 0x50 then break end + if value == 0x57 or value == 0x58 or value == 0x5F then + assert(pending > #substitutions, + symbol.name .. ": unused dynamic text substitutions") + return table.concat(out) + end + out[#out + 1] = self:textGlyph(value) + end + elseif command == 1 or command == 2 or command == 9 then + local expected = substitutions[pending] + assert(expected, symbol.name .. ": missing dynamic text substitution") + assert(command == expected[1], + symbol.name .. ": dynamic text command mismatch") + out[#out + 1] = expected[2] + pending = pending + 1 + address = address + (command == 1 and 2 or 3) + else + error(("%s: unsupported text command $%02X") + :format(symbol.name, command)) + end + end + error(symbol.name .. ": text command stream is too long") +end + +function RomExtractor:extractText() + self:beginStage("Dialogue") + local metadata = self.manifest.text + local texts = {} + for index, label in ipairs(metadata.labels) do + texts[label] = self:decodeTextCommands( + self:symbol(label), metadata.dynamic[label] or {}) + self:tick("Dialogue", index, #metadata.labels) + end + local trainerHeaders = {} + for mapLabel, headers in pairs(metadata.trainerHeaders) do + local converted = {} + for index, header in pairs(headers) do converted[tonumber(index)] = header end + trainerHeaders[mapLabel] = converted + end + self:write("text", texts) + self:write("text_pointers", metadata.pointers) + self:write("trainer_headers", trainerHeaders) + return { + texts = texts, pointers = metadata.pointers, + trainerHeaders = trainerHeaders, + } +end + +function RomExtractor:raw2bpp(label, width, height, relative, options) + options = options or {} + local expected = width * height / 4 + local length = options.storedLength or expected + local symbol = self:symbol(label) + local raw = self.rom:bytes(symbol.bank, symbol.address, length) + while #raw < expected do raw[#raw + 1] = 0 end + if options.columns then + raw = ImageWriter.columnsToRows(raw, width / 8, height / 8) + end + local image = ImageWriter.decode2bpp( + raw, width, height, options.transparent) + if options.matte then image = ImageWriter.matteColor0(image) end + self:save(image, relative) + return image +end + +function RomExtractor:raw1bpp(label, width, height, relative, transparent) + local symbol = self:symbol(label) + local raw = self.rom:bytes( + symbol.bank, symbol.address, width * height / 8) + local image = ImageWriter.decode1bpp(raw, width, height, transparent) + self:save(image, relative) + return image +end + +function RomExtractor:extractField() + self:beginStage("Interface artwork") + local done, total = 0, 48 + local function tick() + done = done + 1 + self:tick("Interface artwork", math.min(done, total), total) + end + + self:raw2bpp("PokemonLogoGraphics", 128, 56, + "title/pokemon_logo.png"); tick() + self:raw1bpp("Version_GFX", 80, 8, + "title/red_version.png"); tick() + self:raw2bpp("PlayerCharacterTitleGraphics", 40, 56, + "title/player.png", { matte = true }); tick() + self:raw2bpp("NintendoCopyrightLogoGraphics", 152, 8, + "title/copyright.png"); tick() + self:raw2bpp("GameFreakLogoGraphics", 72, 8, + "title/gamefreak_inc.png"); tick() + + local fallingStar = self:raw2bpp( + "FallingStar", 8, 8, "intro/falling_star.png", + { transparent = true }) + tick() + local blink = ImageWriter.blank(8, 8, 1, 1, 1, 0) + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = fallingStar:getPixel(x, y) + if a ~= 0 and math.abs(r - 2 / 3) < 0.001 then + blink:setPixel(x, y, r, g, b, a) + end + end + end + self:save(blink, "intro/falling_star_blink.png"); tick() + + local gameFreak = self:symbol("GameFreakIntro") + local presentsLength = 104 * 8 / 4 + local presents = ImageWriter.decode2bpp( + self.rom:bytes(gameFreak.bank, gameFreak.address, presentsLength), + 104, 8, true) + self:save(presents, "intro/gamefreak_presents.png"); tick() + self:save(ImageWriter.decode2bpp( + self.rom:bytes(gameFreak.bank, + gameFreak.address + presentsLength, 16 * 24 / 4), + 16, 24, true), "intro/gamefreak_logo.png"); tick() + + local textImage = ImageWriter.blank(80, 8, 1, 1, 1, 0) + local textTiles = { 0, 1, 2, 3, false, 4, 5, 3, 1, 6 } + for index, tile in ipairs(textTiles) do + if tile then + ImageWriter.blit(textImage, presents, (index - 1) * 8, 0, + tile * 8, 0, 8, 8) + end + end + self:save(textImage, "intro/gamefreak_text.png"); tick() + + local moveTiles = self:symbol("MoveAnimationTiles1") + local star = ImageWriter.blank(16, 16, 1, 1, 1, 0) + for _, spec in ipairs({ { 0, 3 }, { 1, 19 } }) do + local tile = ImageWriter.decode2bpp(self.rom:bytes( + moveTiles.bank, moveTiles.address + spec[2] * 16, 16), + 8, 8, true) + ImageWriter.blit(star, tile, 0, spec[1] * 8) + ImageWriter.blit(star, tile, 8, spec[1] * 8, 0, 0, 8, 8, true) + end + self:save(star, "intro/big_star.png"); tick() + + local gengar = self:symbol("FightIntroBackMon") + local gengarRaw = self.rom:bytes( + gengar.bank, gengar.address, 96 * 16) + local gengarTiles = {} + for offset = 1, #gengarRaw, 16 do + local raw = {} + for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end + gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8) + end + for number = 1, 3 do + local tilemap = self:symbol("GengarIntroTiles" .. number) + local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49) + local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0) + for index, tileId in ipairs(tileIds) do + ImageWriter.blit(pose, gengarTiles[tileId + 1], + (index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8) + end + pose = ImageWriter.matteColor0(pose) + self:save(pose, "intro/gengar_" .. number .. ".png"); tick() + end + + for number, label in ipairs({ + "FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3", + }) do + self:raw2bpp(label, 48, 48, + "intro/red_nidorino_" .. number .. ".png", + { transparent = true, columns = true }) + tick() + end + for number = 1, 2 do + self:writeCompressedPic( + "ShrinkPic" .. number, "intro/shrink" .. number .. ".png") + tick() + end + + self:raw2bpp("SlotMachineTiles1", 128, 24, + "slots/red_slots_1.png", { storedLength = 0x250 }); tick() + local slotSheet = self:raw2bpp( + "SlotMachineTiles2", 32, 48, "slots/red_slots_2.png") + tick() + local transparentSlots = ImageWriter.blank(32, 48, 1, 1, 1, 0) + for y = 0, 47 do + for x = 0, 31 do + local r, g, b, a = slotSheet:getPixel(x, y) + transparentSlots:setPixel(x, y, r, g, b, + r == 1 and g == 1 and b == 1 and a == 1 and 0 or a) + end + end + local slotOrder = self.manifest.field.slotSymbols.order + local symbolSheet = ImageWriter.blank(#slotOrder * 16, 16, 1, 1, 1, 0) + for index, name in ipairs(slotOrder) do + local value = self.manifest.field.slotSymbols.symbols[name].tiles + local high, low = math.floor(value / 0x100), value % 0x100 + for _, row in ipairs({ { 0, high }, { 1, low } }) do + local x, y = row[2] % 4 * 8, math.floor(row[2] / 4) * 8 + ImageWriter.blit(symbolSheet, transparentSlots, + (index - 1) * 16, row[1] * 8, x, y, 16, 8) + end + end + self:save(symbolSheet, "slots/symbols.png"); tick() + + local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0) + for index, label in ipairs({ + "ShockEmote", "QuestionEmote", "HappyEmote", + }) do + local symbol = self:symbol(label) + local image = ImageWriter.decode2bpp( + self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true) + ImageWriter.blit(emotes, image, (index - 1) * 16, 0) + end + self:save(emotes, "emotes.png"); tick() + + self:raw1bpp("LedgeHoppingShadow", 8, 8, + "fx/shadow.png", true); tick() + for _, spec in ipairs({ + { "RedFishingRodTiles", 8, 24, "fishing_rod.png" }, + { "RedFishingTilesSide", 16, 8, "red_fish_side.png" }, + { "RedFishingTilesFront", 16, 8, "red_fish_front.png" }, + { "RedFishingTilesBack", 16, 8, "red_fish_back.png" }, + { "PokeCenterFlashingMonitorAndHealBall", 8, 16, "heal_machine.png" }, + { "SSAnneSmokePuffTile", 8, 8, "smoke.png" }, + }) do + self:raw2bpp(spec[1], spec[2], spec[3], + "fx/" .. spec[4], { transparent = true }) + tick() + end + self:raw2bpp("BattleTransitionTile", 8, 8, + "fx/battle_transition.png"); tick() + self:raw2bpp("PokedexTileGraphics", 24, 48, + "fx/pokedex.png"); tick() + + self:raw2bpp("HpBarAndStatusGraphics", 120, 16, + "battle/font_battle_extra.png", { transparent = true }); tick() + for number, label in ipairs({ + "BattleHudTiles1", "BattleHudTiles2", "BattleHudTiles3", + }) do + self:raw1bpp(label, 24, 8, + "battle/battle_hud_" .. number .. ".png", true) + tick() + end + + local theEnd = self:symbol("TheEndGfx") + local interleaved = self.rom:bytes( + theEnd.bank, theEnd.address, 160) + local reordered = {} + for column = 0, 4 do + for offset = 1, 16 do + reordered[column * 16 + offset] = + interleaved[column * 32 + offset] + reordered[(column + 5) * 16 + offset] = + interleaved[column * 32 + 16 + offset] + end + end + self:save(ImageWriter.decode2bpp(reordered, 40, 16), + "credits/the_end.png"); tick() + self:raw2bpp("WorldMapTileGraphics", 32, 32, + "townmap/tiles.png"); tick() + self:raw1bpp("TownMapCursor", 16, 16, + "townmap/cursor.png", true); tick() + + local data = copy(self.manifest.field) + local adjacency = data.hiddenExtras.trashCans.adjacent + local converted = {} + for index, values in pairs(adjacency) do converted[tonumber(index)] = values end + data.hiddenExtras.trashCans.adjacent = converted + data.source = "canonical Pokemon Red ROM + bundled port metadata" + self:write("field", data) + self:tick("Interface artwork", total, total) + return data +end + +function RomExtractor:extractAudio() + self:beginStage("Sound programs") + local metadata = copy(self.manifest.audio) + local bankOrder = { 2, 8, 31 } + local chunks = {} + for index, bank in ipairs(bankOrder) do + local first = Rom.offset(bank, 0x4000) + 1 + chunks[index] = self.rom.data:sub(first, first + 0x3FFF) + self:tick("Sound programs", index, #bankOrder + 2) + end + local ok, writeError = love.filesystem.createDirectory( + "assets/generated/audio") + if ok == false then error("could not create audio cache: " .. tostring(writeError)) end + ok, writeError = love.filesystem.write( + "assets/generated/audio/programs.bin", table.concat(chunks)) + if not ok then error("could not write audio programs: " .. tostring(writeError)) end + + local songs = {} + for name, header in pairs(metadata.musicHeaders) do + songs[name] = header + end + local cries = {} + local cryData = metadata.cryData + for index, species in ipairs(self.manifest.constants.speciesOrder) do + local row = self.rom:bytes( + cryData.bank, cryData.address + (index - 1) * 3, 3) + if not startsWith(species, "MISSINGNO") + and not startsWith(species, "UNUSED") then + cries[species] = { + header = metadata.cryHeaders[tostring(row[1])], + pitch = row[2], + length = row[3], + } + end + end + metadata.runtime = true + metadata.programFile = "assets/generated/audio/programs.bin" + metadata.bankOrder = bankOrder + metadata.songs = songs + metadata.sfx = metadata.sfxHeaders + metadata.cries = cries + metadata.source = "canonical Pokemon Red ROM sound programs" + self:write("audio", metadata) + self:tick("Sound programs", #bankOrder + 2, #bankOrder + 2) + return metadata +end + +function RomExtractor:run() + local results = {} + results.constants = self:extractConstants() + results.tilesets = self:extractTilesets() + results.maps = self:extractMaps() + results.font = self:extractFont() + results.sprites = self:extractSprites() + results.moves = self:extractMoves() + results.battle_anims = self:extractBattleAnimations() + results.items = self:extractItems() + results.type_chart = self:extractTypeChart() + results.palettes = self:extractPalettes() + results.icons = self:extractIcons() + results.pokemon = self:extractPokemon() + results.trainers = self:extractTrainers() + results.encounters = self:extractEncounters() + results.text = self:extractText() + results.field = self:extractField() + results.audio = self:extractAudio() + if self.progress then + self.progress(STAGE_COUNT, STAGE_COUNT, "Ready", 1, 1) + end + return results +end + +return RomExtractor diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua new file mode 100644 index 00000000..8a5dbe68 --- /dev/null +++ b/src/import/RomImporter.lua @@ -0,0 +1,406 @@ +local RomImporter = {} +RomImporter.__index = RomImporter + +local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" +local CACHE_MARKER = "rom-cache-v5:" .. ROM_SHA1 +local MARKER_PATH = "rom-cache.complete" +local COMMUNITY_URL = "https://bois.icu" +local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. + "or a link from the discord that bryanthaboi himself posted, just know " .. + "it might have been tampered with. go to the discord to verify " .. + COMMUNITY_URL .. " (or click the logo above)" +local REQUIRED_FILES = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/text.lua", + "data/generated/field.lua", + "data/generated/battle_anims.lua", + "assets/generated/title/pokemon_logo.png", + "assets/generated/fonts/font.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/anims/move_anim_0.png", + "assets/generated/battle/anims/move_anim_1.png", + "assets/generated/audio/programs.bin", +} + +local function allRequiredFilesExist() + for _, path in ipairs(REQUIRED_FILES) do + if not love.filesystem.getInfo(path, "file") then return false end + end + return true +end + +local function sourceTreeHasData() + if not allRequiredFilesExist() or not love.filesystem.getRealDirectory then + return false + end + local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) + return real == love.filesystem.getSource() +end + +function RomImporter.isReady() + if sourceTreeHasData() then return true end + return love.filesystem.read(MARKER_PATH) == CACHE_MARKER + and allRequiredFilesExist() +end + +local function removeTree(path) + local info = love.filesystem.getInfo(path) + if not info then return end + if info.type == "directory" then + for _, child in ipairs(love.filesystem.getDirectoryItems(path)) do + removeTree(path .. "/" .. child) + end + end + if love.filesystem.getRealDirectory + and love.filesystem.getRealDirectory(path) + ~= love.filesystem.getSaveDirectory() then + return + end + local ok, err = love.filesystem.remove(path) + if ok == false then + error("could not remove stale cache: " .. tostring(err)) + end +end + +local function decodeManifest() + local raw, readError = love.filesystem.read("tools/rom_manifest.json") + if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end + local Json = require("src.link.Json") + local manifest, decodeError = Json.decode(raw) + if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end + assert(manifest.romSha1 == ROM_SHA1, "ROM import metadata version mismatch") + return manifest +end + +local function sha1(data) + local digest = love.data.hash("sha1", data) + if type(digest) == "userdata" and digest.getString then + digest = digest:getString() + end + return love.data.encode("string", "hex", digest) +end + +local function readExternalPath(path) + local file, openError = io.open(path, "rb") + if not file then return nil, openError end + local data = file:read("*a") + file:close() + return data +end + +local function readDroppedFile(file) + local ok, openError = file:open("r") + if not ok then return nil, openError end + local data, readError = file:read(file:getSize()) + file:close() + return data, readError +end + +local function trim(value) + return value and value:gsub("^%s+", ""):gsub("%s+$", "") or "" +end + +local function commandOutput(command) + local pipe = io.popen(command, "r") + if not pipe then return nil end + local result = pipe:read("*a") + pipe:close() + result = trim(result) + return result ~= "" and result or nil +end + +local function chooseRom() + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + [[osascript -e 'POSIX path of (choose file with prompt "Choose your Pokemon Red ROM" of type {"gb"})' 2>/dev/null]]) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='Choose your Pokemon Red ROM';", + "$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + [[zenity --file-selection --title="Choose your Pokemon Red ROM" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]]) + if path then return path end + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]]) + end + return nil +end + +function RomImporter.new(onComplete) + return setmetatable({ + onComplete = onComplete, + logo = love.graphics.newImage("assets/logo/logo.png"), + bcg = love.graphics.newImage("assets/logo/bcg.png"), + state = "waiting", + status = "Choose or drop a Pokemon Red ROM", + detail = "The ROM is verified before any files are created.", + progress = 0, + stageCurrent = 0, + stageTotal = 1, + pulse = 0, + button = {}, + }, RomImporter) +end + +function RomImporter:setError(message) + self.state = "error" + self.status = "That ROM could not be imported" + self.detail = tostring(message) + self.progress = 0 + self.worker = nil + self.romData = nil +end + +function RomImporter:startData(data, displayName) + if self.state == "working" then return end + if type(data) ~= "string" then + self:setError("The selected file could not be read.") + return + end + if #data ~= 1024 * 1024 then + self:setError(("Expected a 1 MiB Pokemon Red ROM; this file is %.2f MiB.") + :format(#data / 1024 / 1024)) + return + end + + self.state = "working" + self.status = "Verifying ROM" + self.detail = displayName or "Pokemon Red" + self.progress = 0 + self.romData = data + self.worker = coroutine.create(function() + local actualHash = sha1(self.romData) + if actualHash ~= ROM_SHA1 then + error(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon Red ROM.") + :format(actualHash)) + end + self.status = "Preparing private game data" + coroutine.yield() + removeTree("data/generated") + removeTree("assets/generated") + love.filesystem.remove(MARKER_PATH) + + local manifest = decodeManifest() + local RomExtractor = require("src.import.RomExtractor") + local extractor = RomExtractor.new(self.romData, manifest, + function(progress, total, stage, current, stageTotal) + self.status = stage + self.progress = progress / total + self.stageCurrent = current + self.stageTotal = stageTotal + coroutine.yield() + end) + extractor:run() + self.romData = nil + collectgarbage("collect") + local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) + if not ok then error("could not finish the private cache: " .. tostring(writeError)) end + self.state = "complete" + self.status = "Ready" + self.detail = "Starting Pokemon Red..." + self.progress = 1 + if self.onComplete then self.onComplete() end + end) +end + +function RomImporter:startPath(path) + if not path then return end + local data, readError = readExternalPath(path) + if not data then + self:setError("Could not read the selected file: " .. tostring(readError)) + return + end + self:startData(data, path:match("[^/\\]+$") or path) +end + +function RomImporter:filedropped(file) + if self.state == "working" then return end + local data, readError = readDroppedFile(file) + if not data then + self:setError("Could not read the dropped file: " .. tostring(readError)) + return + end + self:startData(data, file:getFilename()) +end + +function RomImporter:choose() + if self.state == "working" then return end + local path = chooseRom() + if path then + self:startPath(path) + elseif love.system.getOS() ~= "OS X" + and love.system.getOS() ~= "Windows" + and love.system.getOS() ~= "Linux" then + self:setError("File selection is unavailable here. Drop the .gb file onto the window.") + end +end + +function RomImporter:update(dt) + self.pulse = self.pulse + dt + if self.state ~= "working" or not self.worker then return end + local started = love.timer.getTime() + repeat + local ok, workerError = coroutine.resume(self.worker) + if not ok then + print(debug.traceback(self.worker, tostring(workerError))) + self:setError(tostring(workerError)) + return + end + if coroutine.status(self.worker) == "dead" then + self.worker = nil + return + end + until love.timer.getTime() - started >= 0.008 +end + +local function setColor255(r, g, b, a) + love.graphics.setColor(r / 255, g / 255, b / 255, (a or 255) / 255) +end + +local function printCentered(text, y, font, width) + love.graphics.setFont(font) + love.graphics.printf(text, 0, y, width, "center") +end + +function RomImporter:draw() + local width, height = love.graphics.getDimensions() + setColor255(241, 243, 232) + love.graphics.rectangle("fill", 0, 0, width, height) + setColor255(181, 35, 42) + love.graphics.rectangle("fill", 0, 0, width, math.max(8, height * 0.025)) + + local fontKey = ("%dx%d"):format(width, height) + if self.fontKey ~= fontKey then + self.fontKey = fontKey + self.bodyFont = love.graphics.newFont( + math.max(16, math.min(22, height * 0.038))) + self.smallFont = love.graphics.newFont( + math.max(13, math.min(17, height * 0.029))) + self.warningFont = love.graphics.newFont( + math.max(10, math.min(12, height * 0.022))) + end + local bodyFont, smallFont, warningFont = + self.bodyFont, self.smallFont, self.warningFont + local contentWidth = math.min(width - 40, 520) + local left = (width - contentWidth) / 2 + + local logoWidth, logoHeight = self.logo:getDimensions() + local logoScale = math.min( + math.min(width - 48, 420) / logoWidth, + height * 0.15 / logoHeight) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw( + self.logo, + (width - logoWidth * logoScale) / 2, + height * 0.075, + 0, logoScale, logoScale) + setColor255(74, 88, 72) + printCentered("FIRST RUN", height * 0.205, smallFont, width) + + local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31) + setColor255(215, 220, 202) + love.graphics.rectangle("fill", left, zoneY, contentWidth, zoneH) + setColor255(74, 88, 72) + love.graphics.setLineWidth(2) + love.graphics.rectangle("line", left, zoneY, contentWidth, zoneH) + + setColor255(25, 31, 28) + printCentered(self.status, zoneY + zoneH * 0.25, bodyFont, width) + setColor255(74, 88, 72) + love.graphics.setFont(smallFont) + local _, wrapped = smallFont:getWrap(self.detail, contentWidth - 48) + local visible = {} + for index = 1, math.min(#wrapped, 3) do visible[index] = wrapped[index] end + love.graphics.printf(table.concat(visible, "\n"), + left + 24, zoneY + zoneH * 0.52, contentWidth - 48, "center") + + if self.state == "working" or self.state == "complete" then + local barY = zoneY + zoneH - 24 + setColor255(164, 172, 151) + love.graphics.rectangle("fill", left + 24, barY, contentWidth - 48, 8) + setColor255(181, 35, 42) + love.graphics.rectangle("fill", left + 24, barY, + (contentWidth - 48) * self.progress, 8) + else + local buttonWidth = math.min(260, contentWidth - 80) + local buttonHeight = math.max(46, math.min(56, height * 0.09)) + local buttonX = (width - buttonWidth) / 2 + local buttonY = math.min(height - buttonHeight - 34, zoneY + zoneH + 36) + self.button = { + x = buttonX, y = buttonY, width = buttonWidth, height = buttonHeight, + } + setColor255(25, 31, 28) + love.graphics.rectangle("fill", buttonX, buttonY, buttonWidth, buttonHeight) + setColor255(255, 255, 255) + love.graphics.setFont(bodyFont) + love.graphics.printf("Choose ROM", buttonX, + buttonY + (buttonHeight - bodyFont:getHeight()) / 2, + buttonWidth, "center") + setColor255(74, 88, 72) + love.graphics.setFont(smallFont) + love.graphics.printf("or drop the .gb file here", + 0, buttonY + buttonHeight + 12, width, "center") + end + + local bcgWidth, bcgHeight = self.bcg:getDimensions() + love.graphics.setFont(warningFont) + local warningWidth = math.min(width - 32, 600) + local _, warningLines = warningFont:getWrap(TRUST_WARNING, warningWidth) + local warningHeight = #warningLines * warningFont:getHeight() + local warningY = height - warningHeight - 8 + local bcgScale = math.min( + math.min(width - 48, 220) / bcgWidth, + height * 0.08 / bcgHeight) + local bcgDrawWidth = bcgWidth * bcgScale + local bcgDrawHeight = bcgHeight * bcgScale + local bcgX = (width - bcgDrawWidth) / 2 + local bcgY = warningY - bcgDrawHeight - 8 + self.bcgButton = { + x = bcgX, y = bcgY, + width = bcgDrawWidth, height = bcgDrawHeight, + } + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw( + self.bcg, + bcgX, bcgY, + 0, bcgScale, bcgScale) + setColor255(74, 88, 72) + love.graphics.printf( + TRUST_WARNING, + (width - warningWidth) / 2, warningY, + warningWidth, "center") + love.graphics.setColor(1, 1, 1, 1) +end + +function RomImporter:mousepressed(x, y, button) + if button ~= 1 then return end + local logo = self.bcgButton or {} + if x >= (logo.x or 0) and x <= (logo.x or 0) + (logo.width or 0) + and y >= (logo.y or 0) and y <= (logo.y or 0) + (logo.height or 0) then + love.system.openURL(COMMUNITY_URL) + return + end + if self.state == "working" then return end + local rect = self.button + if x >= (rect.x or 0) and x <= (rect.x or 0) + (rect.width or 0) + and y >= (rect.y or 0) and y <= (rect.y or 0) + (rect.height or 0) then + self:choose() + end +end + +function RomImporter:keypressed(key) + if (key == "return" or key == "space") and self.state ~= "working" then + self:choose() + end +end + +return RomImporter diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua new file mode 100644 index 00000000..e04a596c --- /dev/null +++ b/src/inventory/Bag.lua @@ -0,0 +1,85 @@ +-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm): +-- a distinct item id occupies one slot regardless of quantity; badges +-- live in the inventory table but are not bag items. save.bagOrder +-- keeps acquisition order like wBagItems (SELECT can reorder it). + +local Bag = {} + +Bag.CAPACITY = 20 + +local function isBadge(id) + return id:find("BADGE", 1, true) ~= nil +end + +function Bag.slots(save) + local n = 0 + for id in pairs(save.inventory) do + if not isBadge(id) then n = n + 1 end + end + return n +end + +-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for +-- saves from before the order existed, then maintained incrementally. +function Bag.order(save) + local order = save.bagOrder + if not order then + order = {} + for id in pairs(save.inventory) do + if not isBadge(id) then table.insert(order, id) end + end + table.sort(order) + save.bagOrder = order + end + -- drop stale ids, append unknown ones (defensive against direct + -- inventory writes) + local seen = {} + for i = #order, 1, -1 do + local id = order[i] + if not save.inventory[id] or seen[id] then + table.remove(order, i) + else + seen[id] = true + end + end + for id in pairs(save.inventory) do + if not isBadge(id) and not seen[id] then table.insert(order, id) end + end + return order +end + +-- Add qty of an item; returns false (and adds nothing) when a new slot +-- is needed and the bag is full, or when the stack would pass 99 +-- (AddItemToInventory's per-slot quantity cap). +function Bag.add(save, id, qty) + local inv = save.inventory + if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then + return false + end + if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then + return false + end + local isNew = not inv[id] + inv[id] = (inv[id] or 0) + (qty or 1) + if isNew and not isBadge(id) then + table.insert(Bag.order(save), id) + end + return true +end + +-- Remove qty (default 1); clears the slot and its order entry at zero. +function Bag.remove(save, id, qty) + local inv = save.inventory + inv[id] = (inv[id] or 0) - (qty or 1) + if inv[id] <= 0 then + inv[id] = nil + local order = save.bagOrder + if order then + for i, oid in ipairs(order) do + if oid == id then table.remove(order, i) break end + end + end + end +end + +return Bag diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua new file mode 100644 index 00000000..0e00be94 --- /dev/null +++ b/src/inventory/ItemEffects.lua @@ -0,0 +1,416 @@ +-- Item use effects, ported from engine/items/item_effects.asm. +-- Heal amounts and behaviors match Gen 1; TMs/HMs teach their machine +-- move when the species' tmhm list allows it. +-- +-- ItemEffects.use returns: +-- "consumed", messages item used up +-- "kept", messages used but not consumed (TM kept? no -- +-- HMs and key items) +-- "failed", messages no effect ("It won't have any effect.") +-- "ball" caller must throw it (battle only) +-- "learn", moveId caller must run the learn-move flow + +local Pokemon = require("src.pokemon.Pokemon") +local Flags = require("src.script.Flags") + +local ItemEffects = {} + +local HEAL_AMOUNT = { + POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200, + FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, +} + +local STATUS_HEAL = { + ANTIDOTE = { PSN = true }, BURN_HEAL = { BRN = true }, + ICE_HEAL = { FRZ = true }, AWAKENING = { SLP = true }, + PARLYZ_HEAL = { PAR = true }, + FULL_HEAL = { PSN = true, BRN = true, FRZ = true, SLP = true, PAR = true }, +} + +local BALLS = { + POKE_BALL = true, GREAT_BALL = true, ULTRA_BALL = true, + MASTER_BALL = true, SAFARI_BALL = true, +} + +local STONES = { + FIRE_STONE = true, WATER_STONE = true, THUNDER_STONE = true, + LEAF_STONE = true, MOON_STONE = true, +} + +-- vitamins: stat-exp boosters (ItemUseVitamin) +local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense", + CARBOS = "speed", CALCIUM = "special" } + +ItemEffects.BALLS = BALLS + +function ItemEffects.isBall(id) return BALLS[id] or false end +function ItemEffects.isStone(id) return STONES[id] or false end + +-- Does this item need a party-member target? +function ItemEffects.needsTarget(id, itemDef) + 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 STONES[id] + or (itemDef and itemDef.machine) or id == "ETHER" + or id == "MAX_ETHER" or id == "ELIXER" or id == "MAX_ELIXER" + or VITAMINS[id] or id == "PP_UP" +end + +local function monName(data, mon) + return mon.nickname or data.pokemon[mon.species].name +end + +-- Curing the ACTIVE battler clears its Toxic escalation flag +-- (.cureStatusAilment / trainer_ai.asm AICureStatus both do +-- `res BADLY_POISONED`); the raw w*ToxicCounter is NOT reset by item +-- cures in Gen 1, so battle.sideToxic is deliberately left alone. +local function cureActiveToxic(battle, target) + if not battle then return end + for _, b in ipairs({ battle.player, battle.enemy }) do + if b and b.mon == target then b.toxicCounter = nil end + end +end + +-- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat) +local X_ITEMS = { + X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed", + X_SPECIAL = "special", X_ACCURACY = "accuracy", +} + +-- The two static Snorlax encounters (scripts/Route12.asm, Route16.asm). +-- ItemUsePokeFlute only wakes one when the player is on its route, hasn't +-- beaten it yet, and is standing in one of the four cells orthogonally +-- adjacent to it (Route12SnorlaxFluteCoords/Route16SnorlaxFluteCoords are +-- exactly Snorlax's four neighbors, so a Manhattan distance of 1 from the +-- NPC matches them without hand-listing map coordinates here). +local SNORLAX_ROUTES = { + ROUTE_12 = { obj = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX" }, + ROUTE_16 = { obj = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX" }, +} + +-- Is the player adjacent to a not-yet-beaten Snorlax on the current map? +-- Returns the map id and NPC to wake it, or nil. +local function adjacentSleepingSnorlax(save, ow) + local route = ow and ow.map and SNORLAX_ROUTES[ow.map.id] + if not route or Flags.get(save, route.beatFlag) then return nil end + local p = ow.player + if not p then return nil end + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name == route.obj then + if math.abs(p.cellX - npc.cellX) + math.abs(p.cellY - npc.cellY) == 1 then + return ow.map.id, npc + end + return nil + end + end + return nil +end + +-- Use an item on a target party mon (target may be nil for targetless +-- items). data = generated data tables; battle = BattleState when used +-- mid-battle; ow = the overworld (OverworldState), needed only to check +-- Snorlax adjacency for a field-used POKé FLUTE. +function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) + local itemDef = data.items[itemId] + local name = itemDef and itemDef.name or itemId + + -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase + -- 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") then + return "failed", { "OAK: " .. save.player.name + .. "!\nThis isn't the\ntime to use that!" } + end + + if BALLS[itemId] then + return "ball" + end + + -- The POKé FLUTE wakes every sleeping Pokémon on both sides + -- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed. + if itemId == "POKE_FLUTE" then + if not battle then + -- standing next to a not-yet-beaten Snorlax: this is the ONLY way + -- Snorlax wakes -- using the flute from the item-use menu, never + -- just talking to it with the flute in the bag (see + -- data/scripts/story.lua's snorlaxWake) + local mapId, npc = adjacentSleepingSnorlax(save, ow) + if npc then + return "flute_wake", { data.text._PlayedFluteHadEffectText + or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc } + end + -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's + -- PlayedFluteNoEffectText branch) + return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } + end + local woke = false + local function wake(mon) + if mon and mon.status == "SLP" then + mon.status = nil + woke = true + end + end + for _, mon in ipairs(save.party) do wake(mon) end + wake(battle.player and battle.player.mon) + wake(battle.enemy and battle.enemy.mon) + -- WakeUpEntireParty runs on the enemy's bench too + for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end + if not woke then + return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } + end + return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name), + "All sleeping\nPOKéMON woke up!" } + end + + -- battle-only items + if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" + or itemId == "POKE_DOLL" then + if not battle then + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + end + local b = battle.player + if itemId == "X_ACCURACY" then + -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss + -- (not an accuracy stage) + b.xAccuracy = true + return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) } + end + if X_ITEMS[itemId] then + local stat = X_ITEMS[itemId] + local cur = b.stages[stat] or 0 + -- ItemUseXStat removes the item BEFORE running the stat-up + -- effect, so at +6 it is still consumed and StatModifierUpEffect + -- just prints "Nothing happened!" + if cur >= 6 then + return "consumed", { "Nothing happened!" } + end + b.stages[stat] = cur + 1 + return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) } + end + -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume + -- the item, even when it is already active + if itemId == "DIRE_HIT" then + b.focusEnergy = true + return "consumed", { ("%s's\ngetting pumped!"):format(b.name) } + end + if itemId == "GUARD_SPEC" then + b.mist = true + return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) } + end + if itemId == "POKE_DOLL" then + if battle.kind ~= "wild" then + -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles + return "failed", { "OAK: " .. save.player.name + .. "!\nThis isn't the\ntime to use that!" } + end + return "consumed_escape", { "The wild POKéMON\nran away!" } + end + end + + -- PP restores. The ETHERs restore the move the player picked + -- (moveIndex, from the ItemUsePPRestore move menu); the ELIXERs + -- restore every move with no menu. + if itemId == "ETHER" or itemId == "MAX_ETHER" + or itemId == "ELIXER" or itemId == "MAX_ELIXER" then + if not target then return "failed", { "It won't have\nany effect." } end + local restored = false + local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" + 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 + mv.pp = full and maxPP or math.min(maxPP, mv.pp + 10) + return true + end + return false + end + if allMoves then + for _, mv in ipairs(target.moves) do + restored = restore(mv) or restored + end + else + local mv = target.moves[moveIndex or 1] + restored = mv and restore(mv) or false + end + if not restored then + return "failed", { "It won't have\nany effect." } + end + return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) } + end + + local heal = HEAL_AMOUNT[itemId] + if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then + -- a FULL RESTORE on a statused mon already at full HP acts as a + -- Full Heal: cured, consumed, ailment sound (item_effects.asm + -- swaps wCurItem to FULL_HEAL and jumps to .cureStatusAilment) + if itemId == "FULL_RESTORE" and target and target.hp > 0 + and target.hp >= target.stats.hp and target.status then + target.status = nil + cureActiveToxic(battle, target) + require("src.core.Sound").play(data, "Heal_Ailment") + return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } + end + if not target or target.hp <= 0 or target.hp >= target.stats.hp then + return "failed", { "It won't have\nany effect." } + end + if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then + target.hp = target.stats.hp + else + target.hp = math.min(target.stats.hp, target.hp + heal) + end + local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) } + if itemId == "FULL_RESTORE" then + target.status = nil + cureActiveToxic(battle, target) + end + require("src.core.Sound").play(data, "Heal_HP") + return "consumed", msgs + end + + local cures = STATUS_HEAL[itemId] + if cures then + if not target or not target.status or not cures[target.status] then + return "failed", { "It won't have\nany effect." } + end + target.status = nil + cureActiveToxic(battle, target) + require("src.core.Sound").play(data, "Heal_Ailment") + return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } + end + + if itemId == "REVIVE" or itemId == "MAX_REVIVE" then + if not target or target.hp > 0 then + return "failed", { "It won't have\nany effect." } + end + target.status = nil + target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp + require("src.core.Sound").play(data, "Heal_HP") + return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) } + end + + if itemId == "RARE_CANDY" then + if not target or target.level >= 100 then + return "failed", { "It won't have\nany effect." } + end + local Growth = require("src.pokemon.Growth") + local Stats = require("src.pokemon.Stats") + local speciesDef = data.pokemon[target.species] + target.level = target.level + 1 + target.exp = Growth.expForLevel(speciesDef.growthRate, target.level) + local old = target.stats + target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp) + target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp)) + return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) }, + { leveledTo = target.level } + end + + if STONES[itemId] then + if not target then return "failed", { "It won't have\nany effect." } end + local speciesDef = data.pokemon[target.species] + for _, evo in ipairs(speciesDef.evolutions) do + if evo.method == "ITEM" and evo.item == itemId then + return "consumed", nil, { evolveTo = evo.species } + end + end + return "failed", { "It won't have\nany effect." } + end + + -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, + -- engine/items/item_effects.asm) + local vitaminStat = VITAMINS[itemId] + if vitaminStat then + if not target then return "failed", { "It won't have\nany effect." } end + target.statExp = target.statExp or {} + local cur = target.statExp[vitaminStat] or 0 + if cur >= 25600 then + return "failed", { "It won't have\nany effect." } + end + target.statExp[vitaminStat] = math.min(65535, cur + 2560) + local Stats = require("src.pokemon.Stats") + target.stats = Stats.calc(data.pokemon[target.species], target.level, + target.dvs, target.statExp) + target.hp = math.min(target.hp, target.stats.hp) + return "consumed", { ("%s's %s\nrose!"):format(monName(data, target), + vitaminStat == "hp" and "HP" or vitaminStat:upper()) } + end + + -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) + if itemId == "PP_UP" then + if not target then return "failed", { "It won't have\nany effect." } end + local mv = target.moves[moveIndex or 1] + 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) + return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) } + end + return "failed", { "It won't have\nany effect." } + end + + if itemDef and itemDef.machine then + if not target then return "failed", { "It won't have\nany effect." } end + local speciesDef = data.pokemon[target.species] + local ok = false + for _, m in ipairs(speciesDef.tmhm) do + if m == itemDef.machine.move then ok = true break end + end + if not ok then + -- the only item-use refusal with a sound in pokered: item_effects.asm + -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic + -- ItemUseNotTime/NoCyclingAllowedHere paths are silent) + require("src.core.Sound").play(data, "Denied") + return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) } + end + for _, mv in ipairs(target.moves) do + if mv.id == itemDef.machine.move then + return "failed", { "It knows that\nmove already!" } + end + end + -- HMs are never consumed; TMs are single-use + return (itemDef.machine.kind == "HM" and "learnkept" or "learn"), itemDef.machine.move + end + + if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then + if battle then + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + end + return "fish", itemId + end + + if itemId == "BICYCLE" then + if battle then + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + end + return "bicycle" + end + + if itemId == "ESCAPE_ROPE" then + return "escape_rope" + end + if itemId == "TOWN_MAP" then + if battle then + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + end + return "townmap" + end + if itemId == "ITEMFINDER" then + if battle then + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + end + return "itemfinder" + end + if itemId == "COIN_CASE" then + return "failed", { ("Coin count:\n%d"):format(save.coins or 0) } + end + if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then + local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 + save.repelSteps = steps + return "consumed", { ("%s used\n%s!"):format(save.player.name, name) } + end + + return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } +end + +return ItemEffects diff --git a/src/link/Json.lua b/src/link/Json.lua new file mode 100644 index 00000000..9ddb2e30 --- /dev/null +++ b/src/link/Json.lua @@ -0,0 +1,174 @@ +-- Minimal JSON encoder/decoder for the link protocol (objects, arrays, +-- strings, numbers, booleans, null). No unicode escapes beyond \uXXXX +-- pass-through; good enough for our own messages. + +local Json = {} + +local function encodeValue(v, out) + local t = type(v) + if v == nil then + out[#out + 1] = "null" + elseif t == "boolean" then + out[#out + 1] = v and "true" or "false" + elseif t == "number" then + out[#out + 1] = string.format("%.17g", v) + elseif t == "string" then + out[#out + 1] = '"' .. v:gsub('[%c"\\]', function(c) + if c == '"' then return '\\"' end + if c == "\\" then return "\\\\" end + if c == "\n" then return "\\n" end + if c == "\r" then return "\\r" end + if c == "\t" then return "\\t" end + return string.format("\\u%04x", c:byte()) + end) .. '"' + elseif t == "table" then + -- array if [1..n] contiguous + local n = #v + local isArray = n > 0 + if not isArray then + isArray = next(v) == nil -- empty table -> [] + end + if isArray then + out[#out + 1] = "[" + for i = 1, n do + if i > 1 then out[#out + 1] = "," end + encodeValue(v[i], out) + end + out[#out + 1] = "]" + else + out[#out + 1] = "{" + local first = true + for k, val in pairs(v) do + if not first then out[#out + 1] = "," end + first = false + encodeValue(tostring(k), out) + out[#out + 1] = ":" + encodeValue(val, out) + end + out[#out + 1] = "}" + end + else + error("cannot encode " .. t) + end +end + +function Json.encode(v) + local out = {} + encodeValue(v, out) + return table.concat(out) +end + +-- decoder ------------------------------------------------------------- + +local function skipWs(s, i) + return (s:find("[^ \t\r\n]", i)) or (#s + 1) +end + +local decodeValue + +local function decodeString(s, i) + -- i points at opening quote + local out = {} + i = i + 1 + while i <= #s do + local c = s:sub(i, i) + if c == '"' then + return table.concat(out), i + 1 + elseif c == "\\" then + local esc = s:sub(i + 1, i + 1) + if esc == "n" then out[#out + 1] = "\n" + elseif esc == "r" then out[#out + 1] = "\r" + elseif esc == "t" then out[#out + 1] = "\t" + elseif esc == "b" then out[#out + 1] = string.char(8) + elseif esc == "f" then out[#out + 1] = string.char(12) + elseif esc == "u" then + local hex = s:sub(i + 2, i + 5) + local code = tonumber(hex, 16) or 32 + if code < 128 then + out[#out + 1] = string.char(code) + else -- utf8 encode (2-3 bytes covers our charmap) + if code < 0x800 then + out[#out + 1] = string.char(0xC0 + math.floor(code / 0x40), + 0x80 + code % 0x40) + else + out[#out + 1] = string.char(0xE0 + math.floor(code / 0x1000), + 0x80 + math.floor(code / 0x40) % 0x40, + 0x80 + code % 0x40) + end + end + i = i + 4 + else + out[#out + 1] = esc + end + i = i + 2 + else + out[#out + 1] = c + i = i + 1 + end + end + error("unterminated string") +end + +decodeValue = function(s, i) + i = skipWs(s, i) + local c = s:sub(i, i) + if c == '"' then + return decodeString(s, i) + elseif c == "{" then + local obj = {} + i = skipWs(s, i + 1) + if s:sub(i, i) == "}" then return obj, i + 1 end + while true do + local key + key, i = decodeString(s, skipWs(s, i)) + i = skipWs(s, i) + assert(s:sub(i, i) == ":", "expected :") + local val + val, i = decodeValue(s, i + 1) + obj[key] = val + i = skipWs(s, i) + local d = s:sub(i, i) + if d == "}" then return obj, i + 1 end + assert(d == ",", "expected , or }") + i = i + 1 + end + elseif c == "[" then + local arr = {} + i = skipWs(s, i + 1) + if s:sub(i, i) == "]" then return arr, i + 1 end + while true do + local val + val, i = decodeValue(s, i) + arr[#arr + 1] = val + i = skipWs(s, i) + local d = s:sub(i, i) + if d == "]" then return arr, i + 1 end + assert(d == ",", "expected , or ]") + i = i + 1 + end + elseif c == "t" then + assert(s:sub(i, i + 3) == "true") + return true, i + 4 + elseif c == "f" then + assert(s:sub(i, i + 4) == "false") + return false, i + 5 + elseif c == "n" then + assert(s:sub(i, i + 3) == "null") + return nil, i + 4 + else + local numStr = s:match("^-?%d+%.?%d*[eE]?[-+]?%d*", i) + assert(numStr and #numStr > 0, "unexpected character '" .. c .. "'") + return tonumber(numStr), i + #numStr + end +end + +function Json.decode(s) + local ok, v = pcall(function() + local val = select(1, decodeValue(s, 1)) + return val + end) + if ok then return v end + return nil, v +end + +return Json diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua new file mode 100644 index 00000000..7aad236e --- /dev/null +++ b/src/link/LinkBattle.lua @@ -0,0 +1,386 @@ +-- Link battles over the peer-to-peer link (src/link/Net.lua), +-- lockstep-simulated like the real link cable: BOTH sides run the +-- full battle engine (BattleState) locally +-- from mirrored perspectives, on a shared RNG seed the host deals out. +-- Each turn the two chosen actions are exchanged and both machines +-- resolve the turn independently -- identical clamped party copies + +-- identical RNG stream = identical outcomes. A per-turn state hash is +-- exchanged; a mismatch (desync) ends the match as a draw, like a +-- cable pull. +-- +-- Cable rules: no experience, no money, no items; either side may RUN +-- (a draw); a fainted mon is auto-replaced by the next healthy party +-- member (the original prompts; documented divergence). Badge stat +-- boosts don't apply on either side (divergence: Gen 1 famously kept +-- them in link battles). + +local Logger = require("src.core.Logger") +local Protocol = require("src.link.Protocol") +local TurnOrder = require("src.battle.TurnOrder") + +local LinkBattle = {} + +-- Deterministic Park-Miller PRNG: both sides must roll identical +-- streams, so love.math.random can't be used. +local function makeRng(seed) + local s = seed % 2147483647 + if s <= 0 then s = s + 2147483646 end + return function(a, b) + s = (s * 16807) % 2147483647 + if a == nil then return s / 2147483647 end + if b == nil then a, b = 1, a end + return a + (s % (b - a + 1)) + end +end + +-- battler builder shared by both sides -- NO badge boosts, so both +-- machines compute identical stats +local function mkBattler(data, mon, isPlayer) + local def = data.pokemon[mon.species] + local ok, img = pcall(love.graphics.newImage, + isPlayer and def.spriteBack or def.spriteFront) + return { + mon = mon, def = def, isPlayer = isPlayer, stages = {}, + name = mon.nickname or def.name, + curStats = mon.stats, curTypes = def.types, curMoves = mon.moves, + sprite = ok and img or nil, + } +end + +-- canonical (host-side-first) state signature for desync detection +local function stateHash(self, role) + local function sig(b) + return ("%s:%d:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status)) + end + local hostSide = role == "host" and self.player or self.enemy + local guestSide = role == "host" and self.enemy or self.player + return sig(hostSide) .. "|" .. sig(guestSide) +end + +-- opts: { myParty = packed, theirParty = packed, theirName, role = +-- "host"/"guest", seed } +function LinkBattle.new(game, net, opts) + local BattleState = require("src.battle.BattleState") + local role = opts.role + local theirName = opts.theirName or "FOE" + + -- both parties pass through the same pack->unpack clamp on both + -- machines, so the copies are identical everywhere + local myParty, theirParty = {}, {} + for _, p in ipairs(opts.myParty or {}) do + local mon = Protocol.unpackMon(game.data, p) + if mon then table.insert(myParty, mon) end + end + for _, p in ipairs(opts.theirParty or {}) do + local mon = Protocol.unpackMon(game.data, p) + if mon then table.insert(theirParty, mon) end + end + if #myParty == 0 or #theirParty == 0 then + Logger.warn("link: empty party on one side") + end + + -- build on a wild battle and reshape it into the lockstep link battle + local self = BattleState.newWild(game, theirParty[1] and theirParty[1].species + or "RATTATA", 5) + self.kind = "link" + self.linkRole = role + self.net = net + -- BattleState:update only runs while it's the top of the state + -- stack, but the player can push PartyMenu/ChoiceBox/NamingScreen on + -- top of it (forced switch on faint, evolution naming...); the ENet + -- transport must stay serviced regardless, or the peer's actions + -- back up and the link can stall or time out. Game:step services + -- game.linkNet unconditionally every frame. + game.linkNet = net + self.rng = makeRng(opts.seed or 1) + self.player = mkBattler(game.data, myParty[1], true) + self.enemy = mkBattler(game.data, theirParty[1], false) + self.enemyParty = theirParty + self.introText = ("%s wants\nto battle!"):format(theirName) + self.remoteHashes = {} + self.localHashes = {} + + local send = function(msg) net:send(msg) end + + local function endAsDraw(s, text) + if s.linkEnded then return end + s.result = "draw" + s.afterQueue = "finish" + s.phase = "messages" + if text then s:say(text) end + end + + local function orderMove(action) + if action and action.id then return game.data.moves[action.id] end + return nil + end + + -- decode a remote action message against the enemy battler + local function decodeTheirAction(s, msg) + if msg.kind == "move" then + local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1))) + return s.enemy.curMoves[slot] + elseif msg.kind == "struggle" then + return { id = "STRUGGLE", pp = 1, struggle = true } + elseif msg.kind == "locked" then + return s:lockedAction(s.enemy) + end + return nil + end + + local function checkHashes(s) + for turn, localH in pairs(s.localHashes) do + local remoteH = s.remoteHashes[turn] + if remoteH and remoteH ~= localH then + Logger.warn("link: desync on turn %d (%s vs %s)", turn, localH, remoteH) + endAsDraw(s, "Link error!\nThe battle ends\nin a draw.") + return + end + if remoteH then + s.localHashes[turn] = nil + s.remoteHashes[turn] = nil + end + end + end + + -- both actions in hand: resolve the turn identically on both machines + local function resolveLockstep(s, myMsg, theirMsg) + if myMsg.kind == "run" or theirMsg.kind == "run" then + local who = myMsg.kind == "run" and game.save.player.name or theirName + endAsDraw(s, ("%s ran from\nthe battle!"):format(who)) + return + end + s.phase = "messages" + s.afterQueue = "linkNext" + s.turnCount = (s.turnCount or 0) + 1 + + local myAction = myMsg.action + local theirSwitch = theirMsg.kind == "switch" + and math.max(1, math.min(#theirParty, + math.floor(theirMsg.index or 1))) + or nil + + -- switches happen before attacks (both may switch) + if myMsg.kind == "switch" then + local idx = myMsg.index + s:act(function() + s.player = mkBattler(game.data, myParty[idx], true) + s:sayNext(("Go! %s!"):format(s.player.name)) + end) + myAction = nil + end + if theirSwitch then + s:act(function() + s.enemy = mkBattler(game.data, theirParty[theirSwitch], false) + s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) + end) + end + + s:act(function() + local theirAction = decodeTheirAction(s, theirMsg) + if myAction and theirAction then + -- the tie-break roll is shared: the guest inverts it so both + -- machines agree on who goes first + local first = TurnOrder.firstMover(s.player, orderMove(myAction), + s.enemy, orderMove(theirAction), + s.rng, role == "guest") + local order + if first then + order = { { s.player, s.enemy, myAction }, + { s.enemy, s.player, theirAction } } + else + order = { { s.enemy, s.player, theirAction }, + { s.player, s.enemy, myAction } } + end + for _, entry in ipairs(order) do + s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end) + end + elseif myAction then + s:act(function() s:executeAction(s.player, s.enemy, myAction) end) + elseif theirAction then + s:act(function() s:executeAction(s.enemy, s.player, theirAction) end) + end + s:act(function() s:endOfTurn() end) + s:act(function() + if s.linkEnded then return end + local h = stateHash(s, role) + s.localHashes[s.turnCount] = h + send({ type = "hash", turn = s.turnCount, value = h }) + checkHashes(s) + end) + end) + end + + self.pendingMyAction = nil + self.remoteAction = nil + local function tryResolve(s) + if not s.pendingMyAction or not s.remoteAction then return end + local mine, theirs = s.pendingMyAction, s.remoteAction + s.pendingMyAction, s.remoteAction = nil, nil + resolveLockstep(s, mine, theirs) + end + + -- my chosen action: send it and wait for theirs + local function submit(s, msg, localAction) + msg.action = nil + send(msg) + msg.action = localAction + s.pendingMyAction = msg + s.phase = "waitRemote" + tryResolve(s) + end + + self.resolveTurn = function(s, action) + local kind + if action.struggle then + kind = "struggle" + elseif action.special then + kind = "locked" + else + kind = "move" + end + local slot + if kind == "move" then + for i, mv in ipairs(s.player.curMoves) do + if mv == action then slot = i end + end + if not slot then kind = "locked" end -- thrash/rage move instances + end + submit(s, { type = "action", kind = kind, slot = slot }, action) + end + + self.resolveSwitch = function(s, newMon) + for i, mon in ipairs(myParty) do + if mon == newMon then + submit(s, { type = "action", kind = "switch", index = i }, nil) + return + end + end + end + + -- the party menu must offer the clamped link copies + self.openParty = function(s) + local PartyMenu = require("src.ui.PartyMenu") + s.phase = "messages" + s.afterQueue = "menu" + s:ui(function() + return PartyMenu.new(game, { + battle = s, + party = myParty, + onSwitch = function(mon) + if mon == s.player.mon then + s:say(("%s is\nalready out!"):format(s.player.name)) + elseif mon.hp <= 0 then + s:say("There's no will\nto fight!") + else + s:resolveSwitch(mon) + end + end, + }) + end) + end + + self.openItems = function(s) + s:say("Items can't be\nused in a link\nbattle!") + s.phase = "messages" + s.afterQueue = "menu" + end + + self.tryRun = function(s) + submit(s, { type = "action", kind = "run" }, nil) + end + + -- fainted mons auto-replace with the next healthy teammate, in party + -- order, identically on both machines + self.playerMonFainted = function(s) + for _, mon in ipairs(myParty) do + if mon.hp > 0 then + s:act(function() + s.player = mkBattler(game.data, mon, true) + s:sayNext(("Go! %s!"):format(s.player.name)) + end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name, + theirName)) + s.result = "lose" + s.afterQueue = "finish" + end + + self.enemyMonFainted = function(s) + for _, mon in ipairs(theirParty) do + if mon.hp > 0 then + s:act(function() + s.enemy = mkBattler(game.data, mon, false) + s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) + end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName, + game.save.player.name)) + s.result = "win" + s.afterQueue = "finish" + end + + local baseUpdate = self.update + self.update = function(s, dt) + net:update() + for _, msg in ipairs(net:poll()) do + if msg.type == "action" then + s.remoteAction = msg + tryResolve(s) + elseif msg.type == "hash" then + s.remoteHashes[msg.turn or 0] = msg.value + checkHashes(s) + elseif msg.type == "bye" then + -- only a draw if our own simulation hasn't already decided + -- (the winner's bye can arrive while we're still animating) + if not s.result then + endAsDraw(s, ("%s left the\nbattle."):format(theirName)) + end + end + end + if net.closed and not s.linkEnded and not s.result then + endAsDraw(s) + end + if s.phase == "waitRemote" then + return -- the other side is still choosing + end + if s.phase == "messages" and s.afterQueue == "linkNext" then + if not s:updateQueue() then + s.afterQueue = "menu" + s.phase = "menu" + end + return + end + baseUpdate(s, dt) + end + + local baseFinish = self.finish + self.finish = function(s) + if not s.linkEnded then + s.linkEnded = true + send({ type = "bye" }) + end + net:close() + if game.linkNet == net then game.linkNet = nil end + baseFinish(s) + end + + return self +end + +-- backwards-compatible entry points (LinkState passes role explicitly) +function LinkBattle.newHost(game, net, opts) + opts.role = "host" + return LinkBattle.new(game, net, opts) +end + +function LinkBattle.newGuest(game, net, opts) + opts.role = "guest" + return LinkBattle.new(game, net, opts) +end + +return LinkBattle diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua new file mode 100644 index 00000000..faf2b84e --- /dev/null +++ b/src/link/LinkState.lua @@ -0,0 +1,357 @@ +-- Link play UI: one player hosts (the screen shows their LAN address), +-- the other joins by typing that address in. Direct peer-to-peer over +-- lua-enet (bundled with LÖVE), no relay server. + +local Font = require("src.render.Font") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") +local TextBox = require("src.render.TextBox") + +local LinkState = {} +LinkState.__index = LinkState +LinkState.isOpaque = true + +local CURSOR = 0xED + +-- the joiner edits an IPv4 address as 12 digits (three per octet), +-- prefilled with our own LAN IP so usually only the tail needs changing +local function ipDigits(ip) + local digits = {} + local a, b, c, d = (ip or ""):match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + local octets = { tonumber(a) or 192, tonumber(b) or 168, + tonumber(c) or 0, tonumber(d) or 1 } + for _, o in ipairs(octets) do + o = math.min(255, o) + table.insert(digits, math.floor(o / 100)) + table.insert(digits, math.floor(o / 10) % 10) + table.insert(digits, o % 10) + end + return digits +end + +function LinkState.new(game) + local self = setmetatable({}, LinkState) + self.game = game + self.stage = "menu" + self.index = 1 + self.addr = ipDigits(Net.lanIP()) + self.addrPos = 12 -- the last octet is what usually differs + self.status = "" + return self +end + +function LinkState:exitWith(message) + if self.net then self.net:close() end + self.game.stack:pop() + if message then + self.game.stack:push(TextBox.new(self.game, message)) + end +end + +-- ------------------------------------------------------------------- +-- update +-- ------------------------------------------------------------------- + +function LinkState:update(dt) + local input = self.game.input + if self.net then + self.net:update() + if self.net.error and self.stage ~= "menu" then + self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) + return + end + -- the peer vanished without a bye (only once the inbox is drained, + -- so a final message travelling with the disconnect still counts) + if self.net.closed and #self.net.inbox == 0 + and self.stage ~= "menu" and self.stage ~= "addrEntry" + and self.stage ~= "battleRunning" then + self:exitWith("The link was\nbroken.") + return + end + end + + if self.stage == "menu" then + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("b") then + self:exitWith(nil) + elseif input:wasPressed("a") then + self.net = Net.new() + if self.index == 1 then + if self.net:host() then + self.stage = "hosting" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + else + self.stage = "addrEntry" + end + end + + elseif self.stage == "hosting" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "modeSelect" + self.index = 1 + end + + elseif self.stage == "addrEntry" then + if input:wasPressed("b") then self:exitWith(nil) return end + if input:wasPressed("up") then + self.addr[self.addrPos] = (self.addr[self.addrPos] + 1) % 10 + elseif input:wasPressed("down") then + self.addr[self.addrPos] = (self.addr[self.addrPos] - 1) % 10 + elseif input:wasPressed("left") then + self.addrPos = math.max(1, self.addrPos - 1) + elseif input:wasPressed("right") then + self.addrPos = math.min(12, self.addrPos + 1) + elseif input:wasPressed("a") then + local octets = {} + for i = 1, 4 do + local base = (i - 1) * 3 + octets[i] = math.min(255, self.addr[base + 1] * 100 + + self.addr[base + 2] * 10 + + self.addr[base + 3]) + end + if self.net:join(table.concat(octets, ".")) then + self.stage = "joining" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + end + + elseif self.stage == "joining" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "waitMode" + end + + elseif self.stage == "modeSelect" then -- host picks + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("a") then + local mode = self.index == 1 and "trade" or "battle" + self.net:send({ type = "hello", name = self.game.save.player.name, mode = mode }) + self:startMode(mode, true) + elseif input:wasPressed("b") then + self:exitWith(nil) + end + + elseif self.stage == "waitMode" then -- guest waits for host's pick + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "hello" then + self.peerName = msg.name + self:startMode(msg.mode, false) + -- the host's next messages (party, ...) can share this batch; + -- put them back so the new stage's poll sees them + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + break + end + end + + elseif self.stage == "trade" then + self:updateTrade(input) + + elseif self.stage == "battleWait" then + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "party" then + local LinkBattle = require("src.link.LinkBattle") + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirParty = msg.mons, + theirName = self.peerName or "FOE", + seed = self.isHost and self.linkSeed or msg.seed, + } + if self.isHost then + self.game.stack:push(LinkBattle.newHost(self.game, self.net, opts)) + else + self.game.stack:push(LinkBattle.newGuest(self.game, self.net, opts)) + end + self.stage = "battleRunning" + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + break + end + end + + elseif self.stage == "battleRunning" then + if self.game.stack:top() == self then + self:exitWith(nil) -- battle finished + end + end +end + +function LinkState:startMode(mode, isHost) + self.isHost = isHost + if mode == "trade" then + self.stage = "trade" + self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party) + self.net:send({ type = "party", mons = Protocol.packParty(self.game.save.party) }) + self.index = 1 + else + self.stage = "battleWait" + -- the host deals the shared RNG seed for the lockstep simulation + if isHost then + self.linkSeed = love.math.random(1, 2 ^ 30) + end + self.net:send({ type = "party", + mons = Protocol.packParty(self.game.save.party), + seed = self.linkSeed }) + end +end + +-- ------------------------------------------------------------------- +-- trade flow +-- ------------------------------------------------------------------- + +function LinkState:updateTrade(input) + for _, msg in ipairs(self.net:poll()) do + self.trade:handle(msg) + end + local t = self.trade + + if t.stage == "cancelled" then + self:exitWith("The trade was\ncancelled.") + return + end + if t.stage == "done" then + local sent = t.party[t.myPick] + local received, evoTo = t:apply(self.game) + local name = received.nickname or self.game.data.pokemon[received.species].name + self.net:close() + self.game.stack:pop() + local game = self.game + require("src.core.Sound").play(game.data, "Trade_Machine") + local TradeAnim = require("src.ui.TradeAnim") + game.stack:push(TradeAnim.new(game, { + sent = sent, received = received, + onDone = function() + game.stack:push(TextBox.new(game, + ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), + function() + if evoTo then + require("src.pokemon.Evolution").evolve(game, received, evoTo) + end + end)) + end, + })) + return + end + + if t.stage == "picking" and input:wasPressed("up") then + self.index = math.max(1, self.index - 1) + elseif t.stage == "picking" and input:wasPressed("down") then + self.index = math.min(#self.game.save.party, self.index + 1) + elseif 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 + -- self.confirmed == nil guard + self.net:send({ type = "bye" }) + self:exitWith("The trade was\ncancelled.") + elseif t.stage == "picking" and input:wasPressed("a") then + self.net:send(t:pick(self.index)) + elseif t.stage == "confirming" and self.confirmed == nil then + if input:wasPressed("a") then + self.confirmed = true + self.net:send(t:confirm(true)) + end + end +end + +-- ------------------------------------------------------------------- +-- draw +-- ------------------------------------------------------------------- + +local function drawTitle(text) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(text, 8, 6) +end + +function LinkState:draw() + if self.stage == "menu" then + drawTitle("LINK CABLE CLUB") + Font.draw("HOST A GAME", 32, 48) + Font.draw("JOIN A GAME", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + Font.draw("UDP port " .. Net.defaultPort(), 8, 128) + + elseif self.stage == "hosting" then + drawTitle("HOSTING") + Font.draw("Friend joins at:", 16, 48) + Font.draw(self.net.address or "?", 16, 64) + Font.draw("Waiting for join...", 8, 96) + + elseif self.stage == "addrEntry" then + drawTitle("ENTER HOST ADDRESS") + for i = 1, 12 do + local octet = math.floor((i - 1) / 3) -- 0..3 + local x = 16 + (i - 1) * 8 + octet * 8 -- gap for the dots + Font.draw(tostring(self.addr[i]), x, 64) + if i == self.addrPos then + Font.drawCode(0xEE, x, 76) -- ▼ under the active digit + end + end + for octet = 1, 3 do + Font.draw(".", 16 + octet * 32 - 8, 64) + end + Font.draw("Port: " .. Net.defaultPort(), 16, 96) + Font.draw("A: connect B: back", 8, 128) + + elseif self.stage == "joining" then + drawTitle("JOINING...") + Font.draw("Calling...", 8, 56) + Font.draw(self.net.target or "", 8, 72) + + elseif self.stage == "modeSelect" then + drawTitle("CONNECTED!") + Font.draw("TRADE", 32, 48) + Font.draw("BATTLE", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + + elseif self.stage == "waitMode" then + drawTitle("CONNECTED!") + Font.draw("Waiting for the", 16, 56) + Font.draw("host to choose...", 16, 72) + + elseif self.stage == "trade" then + drawTitle("TRADE") + local t = self.trade + Font.draw("YOURS", 8, 20) + for i, mon in ipairs(self.game.save.party) do + local def = self.game.data.pokemon[mon.species] + Font.draw((mon.nickname or def.name):sub(1, 8), 16, 20 + i * 12) + if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end + end + Font.draw("THEIRS", 84, 20) + for i, mon in ipairs(t.theirParty or {}) do + local def = self.game.data.pokemon[mon.species] + Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) + if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end + end + local hint + if t.stage == "waitParty" then hint = "Exchanging data..." + elseif t.stage == "picking" then hint = "Pick one to trade" + elseif t.stage == "waitPick" then hint = "Waiting for them..." + elseif t.stage == "confirming" then + hint = self.confirmed and "Waiting..." or "A: trade B: cancel" + end + Font.draw(hint or "", 8, 132) + + elseif self.stage == "battleWait" or self.stage == "battleRunning" then + drawTitle("LINK BATTLE") + Font.draw("Exchanging data...", 16, 64) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return LinkState diff --git a/src/link/Net.lua b/src/link/Net.lua new file mode 100644 index 00000000..76c61490 --- /dev/null +++ b/src/link/Net.lua @@ -0,0 +1,245 @@ +-- Peer-to-peer link transport over lua-enet (bundled with LÖVE). +-- One player hosts (binds a UDP port); the other joins by address. +-- No external server: messages are JSON objects on ENet's +-- reliable-ordered channel 0. +-- +-- Usage: +-- local net = Net.new() +-- net:host() -- or net:join("192.168.1.20:7777") +-- every frame: net:update(); msgs = net:poll() +-- net.address -- host: "ip:port" to tell the friend +-- net.paired -- true once both ends are connected +-- net:send({ type = "hello" }) +-- +-- Plain luajit (headless tests) has no enet; Net.available() reports +-- that, and Net.loopbackPair() returns two in-memory ends with the +-- same API so the protocol/battle logic stays testable offline. + +local Json = require("src.link.Json") +local Logger = require("src.core.Logger") + +local hasEnet, enet = pcall(require, "enet") +if not hasEnet then enet = nil end + +local Net = {} +Net.__index = Net + +Net.DEFAULT_PORT = 7777 + +function Net.available() + return enet ~= nil +end + +function Net.defaultPort() + return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT +end + +-- monotonic-ish clock for the join timeout +local function now() + if love and love.timer and love.timer.getTime then + return love.timer.getTime() + end + local ok, socket = pcall(require, "socket") + if ok and socket and socket.gettime then return socket.gettime() end + return os.time() +end + +-- best-effort LAN IP to show the host (no packet is sent: connecting a +-- UDP socket just picks the outbound interface) +function Net.lanIP() + local ok, ip = pcall(function() + local socket = require("socket") + local udp = socket.udp() + udp:setpeername("192.0.2.1", 9) -- TEST-NET-1, never routed + local addr = udp:getsockname() + udp:close() + return addr + end) + if ok and ip and ip ~= "0.0.0.0" then return ip end + return nil +end + +function Net.new() + return setmetatable({ + enetHost = nil, -- our enet host object (both ends have one) + peer = nil, -- the connected remote peer + inbox = {}, + outbox = {}, -- messages queued before pairing completes + paired = false, + address = nil, -- host: "ip:port" the other player types in + error = nil, + closed = false, + mode = nil, + joinTimeout = 10, + }, Net) +end + +-- two in-memory ends with the Net API, for tests / offline logic +function Net.loopbackPair() + local function make() + local n = Net.new() + n.paired = true + n.mode = "loopback" + return n + end + local a, b = make(), make() + a.peerEnd, b.peerEnd = b, a + return a, b +end + +function Net:host(port) + if not enet then + self.error = "link needs lua-enet (run the game with LOVE)" + return false + end + port = tonumber(port) or Net.defaultPort() + local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1) + if not ok or not h then + self.error = ("can't open UDP port %d (%s)"):format( + port, tostring(ok and err or h)) + return false + end + self.enetHost = h + self.mode = "hosting" + self.address = ("%s:%d"):format(Net.lanIP() or "?", port) + return true +end + +function Net:join(address) + if not enet then + self.error = "link needs lua-enet (run the game with LOVE)" + return false + end + local host, port = address:match("^(.-):(%d+)$") + host = host or address + port = tonumber(port) or Net.defaultPort() + local target = ("%s:%d"):format(host, port) + local ok, h = pcall(enet.host_create) -- client: no bind address + if not ok or not h then + self.error = "can't create network socket" + return false + end + local okc, peer = pcall(h.connect, h, target, 1) + if not okc or not peer then + pcall(function() h:destroy() end) + self.error = ("bad address %s"):format(target) + return false + end + self.enetHost = h + self.peer = peer + self.mode = "joining" + self.target = target + self.joinDeadline = now() + self.joinTimeout + return true +end + +function Net:send(msg) + if self.closed then return end + if self.peerEnd then -- loopback: re-encode through json like the wire + local decoded = Json.decode(Json.encode(msg)) + if decoded and not self.peerEnd.closed then + table.insert(self.peerEnd.inbox, decoded) + end + return + end + if not self.paired or not self.peer then + table.insert(self.outbox, msg) -- flushed when the connection opens + return + end + local ok, err = pcall(function() + return self.peer:send(Json.encode(msg), 0, "reliable") + end) + if not ok then + self.error = "send failed: " .. tostring(err) + self.closed = true + end +end + +-- pump enet events; decoded JSON messages are queued for poll() +function Net:update() + if self.peerEnd then return end -- loopback needs no pumping + if not self.enetHost or self.closed then return end + while true do + local ok, event = pcall(self.enetHost.service, self.enetHost, 0) + if not ok then + -- an unreachable join target surfaces as a service error + -- (ICMP unreachable on the connected UDP socket) + if self.mode == "joining" and not self.paired then + self.error = ("no answer from\n%s"):format(self.target or "the host") + else + self.error = tostring(event) + end + self.closed = true + return + end + if not event then break end + if event.type == "connect" then + if self.mode == "hosting" and self.peer and self.peer ~= event.peer then + pcall(function() event.peer:disconnect_now() end) -- room is taken + else + self.peer = event.peer + self.paired = true + local queued = self.outbox + self.outbox = {} + for _, msg in ipairs(queued) do self:send(msg) end + end + elseif event.type == "receive" then + local msg = Json.decode(event.data) + if msg then + table.insert(self.inbox, msg) + else + Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60)) + end + elseif event.type == "disconnect" then + if event.peer == self.peer then + self.closed = true + if not self.paired then + self.error = self.error or + ("no answer from\n%s"):format(self.target or "the host") + end + end + end + end + if self.mode == "joining" and not self.paired + and self.joinDeadline and now() > self.joinDeadline then + self.error = ("no answer from\n%s"):format(self.target or "the host") + self.closed = true + pcall(function() self.peer:disconnect_now() end) + end +end + +function Net:poll() + local msgs = self.inbox + self.inbox = {} + return msgs +end + +function Net:close() + if self.peerEnd then + self.closed = true + return + end + if self.enetHost then + if self.peer and self.paired and not self.closed then + -- graceful goodbye: disconnect_later delivers the queued + -- reliables (e.g. the final confirm/bye) before disconnecting; + -- disconnect_now would drop them on both ends. Pump briefly + -- until the handshake completes. + pcall(function() self.peer:disconnect_later() end) + local deadline = now() + 0.5 + while now() < deadline do + local ok, event = pcall(self.enetHost.service, self.enetHost, 10) + if not ok or (event and event.type == "disconnect") then break end + end + elseif self.peer then + pcall(function() self.peer:disconnect_now() end) + end + pcall(function() self.enetHost:flush() end) + pcall(function() self.enetHost:destroy() end) + self.enetHost = nil + self.peer = nil + end + self.closed = true +end + +return Net diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua new file mode 100644 index 00000000..bdcaeada --- /dev/null +++ b/src/link/Protocol.lua @@ -0,0 +1,175 @@ +-- Link protocol helpers: Pokémon serialization and the trade session +-- state machine (pure logic, headless-testable). +-- +-- Message types exchanged after pairing: +-- {type="hello", name, mode} host announces trade|battle +-- {type="party", mons=[...]} full party (both directions) +-- {type="pick", index} trade: chosen party slot +-- {type="confirm", ok=bool} trade: final yes/no +-- {type="action", ...} battle: guest -> host choice +-- {type="event", ...} battle: host -> guest display event +-- {type="bye"} + +local Protocol = {} + +-- serialize a mon instance for the wire (plain data only) +function Protocol.packMon(mon) + local moves = {} + for _, mv in ipairs(mon.moves) do + table.insert(moves, { id = mv.id, pp = mv.pp }) + end + return { + species = mon.species, + level = mon.level, + exp = mon.exp, + hp = mon.hp, + status = mon.status, + nickname = mon.nickname, + dvs = mon.dvs, + statExp = mon.statExp, + moves = moves, + } +end + +-- rebuild a mon locally (recomputes stats from real species data so a +-- tampered packet can't invent stats) +function Protocol.unpackMon(data, packed) + local Stats = require("src.pokemon.Stats") + local Growth = require("src.pokemon.Growth") + local def = data.pokemon[packed.species] + if not def then return nil end + local level = math.max(2, math.min(100, math.floor(packed.level or 5))) + local dvs = {} + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0))) + end + local statExp = {} + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0))) + end + local stats = Stats.calc(def, level, dvs, statExp) + local moves = {} + for _, mv in ipairs(packed.moves or {}) do + if data.moves[mv.id] and #moves < 4 then + table.insert(moves, { + id = mv.id, + pp = math.max(0, math.min(data.moves[mv.id].pp, math.floor(mv.pp or 0))), + }) + end + end + if #moves == 0 then + moves = { { id = "TACKLE", pp = 35 } } + end + return { + species = packed.species, + level = level, + exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))), + dvs = dvs, + statExp = statExp, + stats = stats, + hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))), + status = packed.status, + nickname = packed.nickname, + moves = moves, + } +end + +function Protocol.packParty(party) + local mons = {} + for _, mon in ipairs(party) do + table.insert(mons, Protocol.packMon(mon)) + end + return mons +end + +-- ------------------------------------------------------------------- +-- Trade session: symmetric state machine. Feed it messages; read +-- .stage ("waitParty" -> "picking" -> "waitPick" -> "confirming" -> +-- "done"/"cancelled"). When done, .result = {give=idx, getMon=mon}. +-- ------------------------------------------------------------------- + +local TradeSession = {} +TradeSession.__index = TradeSession +Protocol.TradeSession = TradeSession + +function TradeSession.new(data, party) + return setmetatable({ + data = data, + party = party, + stage = "waitParty", + theirParty = nil, + myPick = nil, + theirPick = nil, + myConfirm = nil, + theirConfirm = nil, + }, TradeSession) +end + +function TradeSession:handle(msg) + if msg.type == "party" then + self.theirParty = {} + for _, packed in ipairs(msg.mons or {}) do + local mon = Protocol.unpackMon(self.data, packed) + if mon then table.insert(self.theirParty, mon) end + end + if self.stage == "waitParty" then self.stage = "picking" end + elseif msg.type == "pick" then + self.theirPick = msg.index + self:advance() + elseif msg.type == "confirm" then + self.theirConfirm = msg.ok + self:advance() + elseif msg.type == "bye" then + self.stage = "cancelled" + end +end + +function TradeSession:pick(index) + self.myPick = index + self:advance() + return { type = "pick", index = index } +end + +function TradeSession:confirm(ok) + self.myConfirm = ok + self:advance() + return { type = "confirm", ok = ok } +end + +function TradeSession:advance() + if self.stage == "picking" and self.myPick then + self.stage = self.theirPick and "confirming" or "waitPick" + elseif self.stage == "waitPick" and self.theirPick then + self.stage = "confirming" + end + if self.stage == "confirming" and self.myConfirm ~= nil and self.theirConfirm ~= nil then + if self.myConfirm and self.theirConfirm then + self.stage = "done" + else + self.stage = "cancelled" + end + end +end + +-- apply the completed trade to the local party; returns the new mon +-- (trade evolutions like Kadabra -> Alakazam trigger on the receiving +-- side, as on a real link cable) +function TradeSession:apply(game) + assert(self.stage == "done", "trade not complete") + local received = self.theirParty[self.theirPick] + received.traded = true -- boosted exp (different OT) + self.party[self.myPick] = received + if game and game.save.pokedex then + game.save.pokedex.seen[received.species] = true + game.save.pokedex.owned[received.species] = true + end + local def = self.data.pokemon[received.species] + for _, evo in ipairs(def.evolutions or {}) do + if evo.method == "TRADE" then + return received, evo.species + end + end + return received, nil +end + +return Protocol diff --git a/src/mods/Events.lua b/src/mods/Events.lua new file mode 100644 index 00000000..a315d1eb --- /dev/null +++ b/src/mods/Events.lua @@ -0,0 +1,35 @@ +local Events = {} +Events.__index = Events + +function Events.new() + return setmetatable({ listeners = {}, sealed = false }, Events) +end + +function Events:on(name, callback, priority) + assert(not self.sealed, "mod events are sealed") + assert(type(name) == "string" and name ~= "", "event name is required") + assert(type(callback) == "function", "event callback must be a function") + local list = self.listeners[name] or {} + self.listeners[name] = list + local entry = { callback = callback, priority = priority or 0 } + list[#list + 1] = entry + table.sort(list, function(a, b) return a.priority > b.priority end) + return function() + for i, candidate in ipairs(list) do + if candidate == entry then table.remove(list, i) break end + end + end +end + +function Events:emit(name, payload) + local list = self.listeners[name] or {} + for _, entry in ipairs(list) do + entry.callback(payload) + end +end + +function Events:seal() + self.sealed = true +end + +return Events diff --git a/src/mods/Hooks.lua b/src/mods/Hooks.lua new file mode 100644 index 00000000..0d4a6d0a --- /dev/null +++ b/src/mods/Hooks.lua @@ -0,0 +1,47 @@ +local Hooks = {} +Hooks.__index = Hooks +local unpack = table.unpack or unpack + +function Hooks.new() + return setmetatable({ chains = {}, sealed = false }, Hooks) +end + +function Hooks:wrap(name, callback, priority) + assert(not self.sealed, "mod hooks are sealed") + assert(type(name) == "string" and name ~= "", "hook name is required") + assert(type(callback) == "function", "hook callback must be a function") + local chain = self.chains[name] or {} + self.chains[name] = chain + local entry = { callback = callback, priority = priority or 0 } + chain[#chain + 1] = entry + table.sort(chain, function(a, b) return a.priority > b.priority end) + return function() + for i, candidate in ipairs(chain) do + if candidate == entry then table.remove(chain, i) break end + end + end +end + +function Hooks:call(name, vanilla, ...) + local chain = self.chains[name] or {} + local args = { ... } + local function run(index, current) + if index > #chain then return current(unpack(args)) end + return chain[index].callback(function(...) + local nextArgs = { ... } + if #nextArgs == 0 then return run(index + 1, current) end + local old = args + args = nextArgs + local result = run(index + 1, current) + args = old + return result + end, unpack(args)) + end + return run(1, vanilla) +end + +function Hooks:seal() + self.sealed = true +end + +return Hooks diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua new file mode 100644 index 00000000..238606ab --- /dev/null +++ b/src/mods/Loader.lua @@ -0,0 +1,237 @@ +local Json = require("src.link.Json") +local Logger = require("src.core.Logger") +local SaveData = require("src.core.SaveData") +local Manifest = require("src.mods.Manifest") +local Registry = require("src.mods.Registry") +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") + +local Loader = {} +Loader.__index = Loader + +local REGISTRY_NAMES = { + "pokemon", "moves", "items", "maps", "tilesets", "encounters", + "trainers", "sprites", "music", "audio", "text", "scripts", "ui", +} + +local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only + +local function readManifest(root) + local raw, err = love.filesystem.read(root .. "/manifest.json") + if not raw then return nil, err end + local data, decodeErr = Json.decode(raw) + if not data then return nil, decodeErr end + local ok, manifest = pcall(Manifest.validate, data, root) + if not ok then return nil, manifest end + return manifest +end + +local function topoSort(mods) + local ordered, visiting, visited = {}, {}, {} + local function visit(id) + if visited[id] then return end + if visiting[id] then error("circular mod dependency involving " .. id) end + local mod = mods[id] + if not mod then error("missing required mod dependency: " .. id) end + visiting[id] = true + for _, dependency in ipairs(mod.manifest.dependencies) do visit(dependency) end + visiting[id], visited[id] = nil, true + ordered[#ordered + 1] = mod + end + local ids = {} + for id in pairs(mods) do ids[#ids + 1] = id end + table.sort(ids, function(a, b) + local pa, pb = mods[a].manifest.priority, mods[b].manifest.priority + if pa == pb then return a < b end + return pa < pb + end) + for _, id in ipairs(ids) do visit(id) end + return ordered +end + +function Loader.new() + local self = setmetatable({ + mods = {}, loaded = {}, errors = {}, initialized = false, + events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, + }, Loader) + for _, name in ipairs(REGISTRY_NAMES) do + self.content[name] = Registry.new(name) + end + self.disabled = {} + return self +end + +function Loader:_loadState() + self.disabled = {} + local options = SaveData.loadOptions() + for id, enabled in pairs(options.mods or {}) do + if enabled == false then self.disabled[id] = true end + end + -- Migrate the original prototype manager's separate state file into the + -- normal persistent options file once. New Game never resets options. + if next(options.mods or {}) == nil and love.filesystem.getInfo + and love.filesystem.getInfo(MOD_STATE_FILE) then + local chunk = love.filesystem.load(MOD_STATE_FILE) + local ok, state = chunk and pcall(chunk) + if ok and type(state) == "table" then + for id, disabled in pairs(state) do + if disabled then + options.mods[id] = false + self.disabled[id] = true + end + end + SaveData.saveOptions(options) + end + end +end + +function Loader:_saveState() + local options = SaveData.loadOptions() + options.mods = options.mods or {} + for id in pairs(self.mods) do + options.mods[id] = not self.disabled[id] + end + SaveData.saveOptions(options) +end + +function Loader:setEnabled(id, enabled) + if not self.mods[id] then return false end + self.disabled[id] = not enabled + self.mods[id].enabled = enabled + self:_saveState() + return true +end + +function Loader:_discover() + if not love.filesystem.getDirectoryItems then return end + local roots = { "mods" } + for _, root in ipairs(roots) do + if love.filesystem.getInfo(root) then + for _, name in ipairs(love.filesystem.getDirectoryItems(root)) do + local path = root .. "/" .. name + local info = love.filesystem.getInfo(path) + if info and info.type == "directory" then + local manifest, err = readManifest(path) + if manifest then + if self.mods[manifest.id] then + self.errors[#self.errors + 1] = manifest.id .. ": duplicate mod id" + else + self.mods[manifest.id] = { manifest = manifest, path = path } + end + else + Logger.warn("mod %s ignored: %s", path, tostring(err)) + end + end + end + end + end +end + +function Loader:_api(mod) + local loader = self + local api = { + id = mod.manifest.id, + version = mod.manifest.version, + path = mod.path, + content = {}, + events = { on = function(_, name, callback, priority) + return loader.events:on(name, callback, priority) + end }, + hooks = { wrap = function(_, name, callback, priority) + return loader.hooks:wrap(name, callback, priority) + end }, + log = { + info = function(_, fmt, ...) Logger.info("[%s] " .. fmt, mod.manifest.id, ...) end, + warn = function(_, fmt, ...) Logger.warn("[%s] " .. fmt, mod.manifest.id, ...) end, + error = function(_, fmt, ...) Logger.error("[%s] " .. fmt, mod.manifest.id, ...) end, + }, + } + for _, name in ipairs(REGISTRY_NAMES) do + api.content[name] = { + register = function(_, id, value) + return loader.content[name]:register(id, value, mod.manifest.id) + end, + override = function(_, id, value) + return loader.content[name]:override(id, value, mod.manifest.id) + end, + get = function(_, id) + return loader.content[name]:get(id) + or (loader.baseData and loader.baseData[name] + and loader.baseData[name][id]) + end, + } + end + api.assets = api.content + function api:read(relative) + local path = self.path .. "/" .. relative + return love.filesystem.read(path) + end + return api +end + +function Loader:_loadMod(mod) + local path = mod.path .. "/" .. mod.manifest.entry + local chunk, err = love.filesystem.load(path) + if not chunk then error(err or ("unable to load " .. path)) end + local api = self:_api(mod) + local result = chunk(api) + if type(result) == "function" then result(api) end +end + +function Loader:load(data) + self.baseData = data + self:_loadState() + self:_discover() + local ok, ordered = pcall(topoSort, self.mods) + if not ok then + self.errors[#self.errors + 1] = ordered + Logger.error("mod dependency resolution failed: %s", tostring(ordered)) + return false + end + for _, mod in ipairs(ordered) do + mod.enabled = not self.disabled[mod.manifest.id] + local success, err = true, nil + if mod.enabled then + success, err = pcall(self._loadMod, self, mod) + end + if success and mod.enabled then + self.loaded[#self.loaded + 1] = mod + Logger.info("loaded mod %s %s", mod.manifest.id, mod.manifest.version) + else + self.errors[#self.errors + 1] = mod.manifest.id .. ": " .. tostring(err) + Logger.error("mod %s failed: %s", mod.manifest.id, tostring(err)) + end + end + -- Native content registrations override the imported base definitions. + for name, registry in pairs(self.content) do + local target = data and data[name] + if name == "music" and data and data.audio then + data.audio.songs = data.audio.songs or {} + target = data.audio.songs + end + if type(target) == "table" then + for id, value in pairs(registry.values) do target[id] = value end + end + end + self.events:emit("mods.loaded", { loader = self, data = data }) + self.events:seal() + self.hooks:seal() + self.initialized = true + return #self.errors == 0 +end + +function Loader:status() + local available, loaded = {}, {} + for _, mod in pairs(self.mods) do + local manifest = {} + for key, value in pairs(mod.manifest) do manifest[key] = value end + manifest.enabled = mod.enabled ~= false + available[#available + 1] = manifest + if manifest.enabled then loaded[#loaded + 1] = manifest end + end + table.sort(available, function(a, b) return a.id < b.id end) + table.sort(loaded, function(a, b) return a.id < b.id end) + return { available = available, loaded = loaded, errors = self.errors } +end + +return Loader diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua new file mode 100644 index 00000000..11385b88 --- /dev/null +++ b/src/mods/ManagerState.lua @@ -0,0 +1,242 @@ +-- Built-in mod manager using the same tile boxes, cursor, spacing, and +-- navigation language as the game's START menu. +local Font = require("src.render.Font") + +local ManagerState = {} +ManagerState.__index = ManagerState +ManagerState.isOpaque = true + +local CURSOR = 0xED +local DOWN_ARROW = 0xEE + +local function wrap(text, width) + local lines = {} + for paragraph in tostring(text or ""):gmatch("[^\n]+") do + local line = "" + for word in paragraph:gmatch("%S+") do + while #word > width do + if line ~= "" then + lines[#lines + 1] = line + line = "" + end + lines[#lines + 1] = word:sub(1, width) + word = word:sub(width + 1) + end + if word ~= "" then + local candidate = line == "" and word or line .. " " .. word + if #candidate > width and line ~= "" then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + end + if line ~= "" then lines[#lines + 1] = line end + end + if #lines == 0 then lines[1] = "" end + return lines +end + +function ManagerState.new(game) + return setmetatable({ + game = game, + mode = "categories", + categoryIndex = 1, + modIndex = 1, + scroll = 1, + restartPending = false, + }, ManagerState) +end + +function ManagerState:enter() + self:rebuildCategories() +end + +function ManagerState:rebuildCategories() + local status = self.game.modStatus or { available = {} } + self.categories = {} + self.byCategory = {} + for _, manifest in ipairs(status.available or {}) do + local category = manifest.category or "OTHER" + self.byCategory[category] = self.byCategory[category] or {} + self.byCategory[category][#self.byCategory[category] + 1] = manifest + end + for category in pairs(self.byCategory) do + self.categories[#self.categories + 1] = category + end + table.sort(self.categories) + self.categoryIndex = math.min(self.categoryIndex, math.max(1, #self.categories)) +end + +function ManagerState:currentMods() + return self.byCategory[self.categories[self.categoryIndex]] or {} +end + +function ManagerState:currentMod() + return self:currentMods()[self.modIndex] +end + +function ManagerState:openCategory() + self.mode = "mods" + self.modIndex = 1 +end + +function ManagerState:openMod() + self.mode = "detail" + self.scroll = 1 +end + +function ManagerState:toggleCurrent() + local manifest = self:currentMod() + if not manifest then return end + self.game.mods:setEnabled(manifest.id, not manifest.enabled) + self.game.modStatus = self.game.mods:status() + self.restartPending = true + self:rebuildCategories() + for _, candidate in ipairs(self:currentMods()) do + if candidate.id == manifest.id then + self.modIndex = _ + break + end + end + self.mode = "detail" +end + +function ManagerState:restartGame() + if self.game.restartWithMods then + self.game:restartWithMods() + elseif love.event and love.event.quit then + love.event.quit("restart") + end +end + +function ManagerState:back() + if self.mode == "detail" then + self.mode = "mods" + elseif self.mode == "mods" then + self.mode = "categories" + else + self.game.stack:pop() + end +end + +function ManagerState:onKeyPressed(key) + local activate = key == "return" or key == "kpenter" or key == "z" + or key == "space" + if key == "escape" or key == "f10" or key == "x" or key == "backspace" then + self:back() + return + end + if self.mode == "categories" then + if key == "up" and #self.categories > 0 then + self.categoryIndex = self.categoryIndex > 1 and self.categoryIndex - 1 or #self.categories + elseif key == "down" and #self.categories > 0 then + self.categoryIndex = self.categoryIndex < #self.categories and self.categoryIndex + 1 or 1 + elseif activate and #self.categories > 0 then + self:openCategory() + end + elseif self.mode == "mods" then + local mods = self:currentMods() + if key == "up" and #mods > 0 then + self.modIndex = self.modIndex > 1 and self.modIndex - 1 or #mods + elseif key == "down" and #mods > 0 then + self.modIndex = self.modIndex < #mods and self.modIndex + 1 or 1 + elseif activate and #mods > 0 then + self:openMod() + end + else + if key == "up" then self.scroll = math.max(1, self.scroll - 1) + elseif key == "down" then self.scroll = self.scroll + 1 + elseif activate then + if self.restartPending then self:restartGame() + else self:toggleCurrent() end + end + end +end + +function ManagerState:update() end + +local function drawList(items, index, tx, ty, tw, th) + local visible = math.max(1, math.floor((th - 2) / 2)) + local first = math.max(1, index - visible + 1) + local y = ty + 1 + for itemIndex = first, math.min(#items, first + visible - 1) do + local itemLines = wrap(items[itemIndex], tw - 2) + if itemIndex == index then + Font.drawCode(CURSOR, (tx + 1) * 8, y * 8) + end + for lineIndex = 1, math.min(2, #itemLines) do + Font.draw(itemLines[lineIndex], (tx + 2) * 8, + (y + lineIndex - 1) * 8) + end + y = y + 2 + end + if #items > first + visible - 1 then + Font.drawCode(DOWN_ARROW, (tx + tw - 2) * 8, (ty + th - 1) * 8) + end +end + +function ManagerState:drawDetail(manifest) + local title = wrap(manifest.name, 16) + Font.draw(title[1], 2 * 8, 4 * 8) + Font.draw(manifest.enabled and "ENABLED" or "DISABLED", 3 * 8, 6 * 8) + local lines = wrap(manifest.description, 16) + -- Rows 8-12 are description, row 13 is deliberately blank, and row 14 + -- is the option/restart action. + local visible = 5 + for row = 1, visible do + local line = lines[self.scroll + row - 1] + if not line then break end + Font.draw(line, 2 * 8, (7 + row) * 8) + end + if self.scroll + visible <= #lines then + Font.drawCode(DOWN_ARROW, 17 * 8, 12 * 8) + end + if self.restartPending then + Font.draw("RESTART REQUIRED", 2 * 8, 14 * 8) + Font.draw("A:RESTART", 11 * 8, 15 * 8) + else + Font.draw(manifest.enabled and "DISABLE" or "ENABLE", 2 * 8, 14 * 8) + Font.draw("A:CHANGE", 11 * 8, 15 * 8) + end +end + +function ManagerState:draw() + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(0, 0, 20, 18) + Font.draw("MOD MENU", 2 * 8, 1 * 8) + + if self.mode == "detail" then + self:drawDetail(self:currentMod()) + return + end + + local categoryItems = {} + for _, category in ipairs(self.categories) do + categoryItems[#categoryItems + 1] = category + end + if #categoryItems == 0 then categoryItems[1] = "NO MODS" end + if self.mode == "categories" then + drawList(categoryItems, self.categoryIndex, 1, 4, 18, 11) + Font.draw("A:OPEN", 2 * 8, 16 * 8) + Font.draw("B:BACK", 12 * 8, 16 * 8) + return + end + + if self.mode == "mods" then + local mods = self:currentMods() + local labels = {} + for _, manifest in ipairs(mods) do + labels[#labels + 1] = (manifest.enabled and "" or "*") .. manifest.name + end + Font.draw(self.categories[self.categoryIndex] or "MODS", 2 * 8, 4 * 8) + drawList(labels, self.modIndex, 1, 6, 18, 9) + Font.draw("A:OPEN", 2 * 8, 16 * 8) + Font.draw("B:BACK", 12 * 8, 16 * 8) + end +end + +return ManagerState diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua new file mode 100644 index 00000000..467cf1d8 --- /dev/null +++ b/src/mods/Manifest.lua @@ -0,0 +1,33 @@ +local Manifest = {} + +local function array(value) + if value == nil then return {} end + assert(type(value) == "table", "manifest arrays must be tables") + return value +end + +function Manifest.validate(raw, path) + assert(type(raw) == "table", "manifest must be an object") + assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"), + "manifest id must contain only letters, numbers, _ or -") + assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") + assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required") + assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required") + return { + id = raw.id, + name = raw.name, + version = raw.version, + entry = raw.entry, + priority = tonumber(raw.priority) or 0, + dependencies = array(raw.dependencies), + optional_dependencies = array(raw.optional_dependencies), + conflicts = array(raw.conflicts), + category = raw.category or "OTHER", + game_version = raw.game_version, + description = raw.description or "", + path = path, + raw = raw, + } +end + +return Manifest diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua new file mode 100644 index 00000000..f6845142 --- /dev/null +++ b/src/mods/Registry.lua @@ -0,0 +1,38 @@ +-- Ordered, namespaced registries used by the native mod API. +-- Mods register definitions here; the loader merges them into the live data +-- only after every enabled mod has initialized successfully. +local Registry = {} +Registry.__index = Registry + +function Registry.new(name) + return setmetatable({ name = name, values = {}, owners = {} }, Registry) +end + +function Registry:register(id, value, owner, replace) + assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(value ~= nil, self.name .. " value is required for " .. id) + if self.values[id] ~= nil and not replace then + error(("%s already registered: %s"):format(self.name, id)) + end + self.values[id] = value + self.owners[id] = owner + return value +end + +function Registry:override(id, value, owner) + return self:register(id, value, owner, true) +end + +function Registry:get(id) + return self.values[id] +end + +function Registry:has(id) + return self.values[id] ~= nil +end + +function Registry:items() + return self.values +end + +return Registry diff --git a/src/pokemon/Boxes.lua b/src/pokemon/Boxes.lua new file mode 100644 index 00000000..5d06d680 --- /dev/null +++ b/src/pokemon/Boxes.lua @@ -0,0 +1,45 @@ +-- PC storage: 12 boxes of 20, like the original (wBoxDataStart / Bill's +-- PC, engine/pokemon/bills_pc.asm). Older saves with a single `box` +-- list are migrated into box 1. + +local Boxes = {} + +Boxes.COUNT = 12 +Boxes.CAPACITY = 20 + +function Boxes.ensure(save) + if not save.boxes then + save.boxes = {} + for i = 1, Boxes.COUNT do save.boxes[i] = {} end + save.currentBox = 1 + if save.box then -- migrate pre-12-box saves + for _, mon in ipairs(save.box) do + table.insert(save.boxes[1], mon) + end + save.box = nil + end + end + save.currentBox = math.max(1, math.min(Boxes.COUNT, save.currentBox or 1)) + return save.boxes +end + +function Boxes.active(save) + return Boxes.ensure(save)[save.currentBox] +end + +-- Deposit into the current box; overflows into the next box with room +-- (divergence: the original refuses the catch when the box is full -- +-- docs/known-differences.md). Returns the box number used, or nil. +function Boxes.deposit(save, mon) + local boxes = Boxes.ensure(save) + for off = 0, Boxes.COUNT - 1 do + local i = ((save.currentBox - 1 + off) % Boxes.COUNT) + 1 + if #boxes[i] < Boxes.CAPACITY then + table.insert(boxes[i], mon) + return i + end + end + return nil +end + +return Boxes diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua new file mode 100644 index 00000000..8d12a3b4 --- /dev/null +++ b/src/pokemon/Evolution.lua @@ -0,0 +1,74 @@ +-- Evolution handling (engine/pokemon/evos_moves.asm semantics): +-- level evolutions trigger after battles once the level is reached, +-- stone evolutions on item use, and trade evolutions when a link trade +-- completes (src/link/Protocol.lua TradeSession:apply). + +local Stats = require("src.pokemon.Stats") +local TextBox = require("src.render.TextBox") + +local Evolution = {} + +-- Find a pending level evolution for a mon (nil if none). +function Evolution.pendingLevelEvo(data, mon) + local def = data.pokemon[mon.species] + for _, evo in ipairs(def.evolutions) do + if evo.method == "LEVEL" and mon.level >= evo.level then + return evo.species + end + end + return nil +end + +-- Mutate the mon into the new species (stats, HP delta, dex flags). +function Evolution.apply(game, mon, newSpecies) + local newDef = game.data.pokemon[newSpecies] + assert(newDef, "evolve into unknown species " .. tostring(newSpecies)) + local hpLost = mon.stats.hp - mon.hp + mon.species = newSpecies + mon.stats = Stats.calc(newDef, mon.level, mon.dvs, mon.statExp) + mon.hp = math.max(1, mon.stats.hp - hpLost) + if game.save.pokedex then + game.save.pokedex.seen[newSpecies] = true + game.save.pokedex.owned[newSpecies] = true + end +end + +-- Play the evolution movie (flashing forms), then apply + text. +-- Headless (no real graphics) falls back to the plain text flow. +function Evolution.evolve(game, mon, newSpecies, onDone) + if love.image and love.image.newImageData then + local EvolutionState = require("src.ui.EvolutionState") + game.stack:push(EvolutionState.new(game, mon, newSpecies, onDone)) + return + end + local oldName = mon.nickname or game.data.pokemon[mon.species].name + Evolution.apply(game, mon, newSpecies) + local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") + :format(oldName, oldName, game.data.pokemon[newSpecies].name) + game.stack:push(TextBox.new(game, msg, onDone)) +end + +-- After-battle hook: evolve everyone who qualifies (queued one at a time). +function Evolution.checkParty(game, onDone) + local pending = {} + for _, mon in ipairs(game.save.party) do + local target = Evolution.pendingLevelEvo(game.data, mon) + if target then + table.insert(pending, { mon = mon, to = target }) + end + end + local i = 0 + local function nextOne() + i = i + 1 + local p = pending[i] + if not p then + if onDone then onDone() end + return + end + Evolution.evolve(game, p.mon, p.to, nextOne) + end + nextOne() + return #pending +end + +return Evolution diff --git a/src/pokemon/Growth.lua b/src/pokemon/Growth.lua new file mode 100644 index 00000000..4bedf0b1 --- /dev/null +++ b/src/pokemon/Growth.lua @@ -0,0 +1,30 @@ +-- Experience growth curves, ported from engine/pokemon/experience.asm +-- (GrowthRateTable coefficients). + +local Growth = {} + +local CURVES = { + MEDIUM_FAST = function(n) return n * n * n end, + SLIGHTLY_FAST = function(n) return math.floor((3 * n * n * n) / 4) + 10 * n * n - 30 end, + SLIGHTLY_SLOW = function(n) return math.floor((3 * n * n * n) / 4) + 20 * n * n - 70 end, + MEDIUM_SLOW = function(n) + return math.floor((6 * n * n * n) / 5) - 15 * n * n + 100 * n - 140 + end, + FAST = function(n) return math.floor((4 * n * n * n) / 5) end, + SLOW = function(n) return math.floor((5 * n * n * n) / 4) end, +} + +function Growth.expForLevel(growthRate, level) + local curve = CURVES[growthRate] or CURVES.MEDIUM_FAST + return math.max(0, curve(level)) +end + +function Growth.levelForExp(growthRate, exp) + local level = 1 + while level < 100 and Growth.expForLevel(growthRate, level + 1) <= exp do + level = level + 1 + end + return level +end + +return Growth diff --git a/src/pokemon/Party.lua b/src/pokemon/Party.lua new file mode 100644 index 00000000..9ada801f --- /dev/null +++ b/src/pokemon/Party.lua @@ -0,0 +1,22 @@ +-- Party helpers (max 6, like the original). + +local Party = {} + +Party.MAX = 6 + +function Party.add(party, mon) + if #party >= Party.MAX then + return false -- box system comes later + end + table.insert(party, mon) + return true +end + +function Party.firstHealthy(party) + for i, mon in ipairs(party) do + if mon.hp > 0 then return mon, i end + end + return nil +end + +return Party diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua new file mode 100644 index 00000000..ba253293 --- /dev/null +++ b/src/pokemon/Pokemon.lua @@ -0,0 +1,70 @@ +-- A Pokémon instance (plain table so it serializes straight into the save). + +local Growth = require("src.pokemon.Growth") +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). +function Pokemon.movesAtLevel(speciesDef, level) + local moves = {} + for _, m in ipairs(speciesDef.level1Moves) do + table.insert(moves, m) + end + for _, entry in ipairs(speciesDef.learnset) do + if entry.level <= level then + table.insert(moves, entry.move) + end + end + while #moves > 4 do + table.remove(moves, 1) + end + return moves +end + +function Pokemon.new(data, species, level, rng) + local def = data.pokemon[species] + assert(def, "unknown species " .. tostring(species)) + local dvs = Stats.randomDVs(rng) + local stats = Stats.calc(def, level, dvs) + local moves = {} + for _, id in ipairs(Pokemon.movesAtLevel(def, level)) do + local mdef = data.moves[id] + table.insert(moves, { id = id, pp = mdef and mdef.pp or 0 }) + end + return { + species = species, + level = level, + exp = Growth.expForLevel(def.growthRate, level), + dvs = dvs, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = stats, + hp = stats.hp, + status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR" + moves = moves, + } +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 + local moves = require("src.core.Data").moves + if moves then + 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) + end + end + end +end + +function Pokemon.isFainted(mon) + return mon.hp <= 0 +end + +return Pokemon diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua new file mode 100644 index 00000000..9c2356f0 --- /dev/null +++ b/src/pokemon/Stats.lua @@ -0,0 +1,60 @@ +-- Gen 1 stat calculation (home/move_mon.asm CalcStat): +-- stat = floor(((base + DV) * 2 + floor(ceil(sqrt(statExp)) / 4)) * level / 100) + 5 +-- HP adds level + 10 instead of 5. +-- The HP DV is derived from the low bits of the other four DVs. + +local Stats = {} + +local ORDER = { "hp", "attack", "defense", "speed", "special" } +Stats.ORDER = ORDER + +function Stats.randomDVs(rng) + rng = rng or love.math.random + local dvs = { + attack = rng(0, 15), + defense = rng(0, 15), + speed = rng(0, 15), + special = rng(0, 15), + } + dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + + (dvs.speed % 2) * 2 + (dvs.special % 2) + return dvs +end + +local function calcOne(base, dv, statExp, level, isHP) + -- CalcStat .statExpLoop finds the smallest b with b*b >= statExp + -- (a ceiling sqrt), capped at 255, and quarters it + local ev = math.floor(math.min(255, math.ceil(math.sqrt(statExp or 0))) / 4) + local v = math.floor(((base + dv) * 2 + ev) * level / 100) + if isHP then + return v + level + 10 + end + return v + 5 +end + +function Stats.calc(speciesDef, level, dvs, statExp) + statExp = statExp or {} + local out = {} + for _, key in ipairs(ORDER) do + out[key] = calcOne(speciesDef.baseStats[key], dvs[key] or 0, + statExp[key], level, key == "hp") + end + return out +end + +-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages +-- -6..+6 map to N/D pairs 25/100 .. 400/100. +local STAGE_MULT = { + [-6] = { 25, 100 }, [-5] = { 28, 100 }, [-4] = { 33, 100 }, [-3] = { 40, 100 }, + [-2] = { 50, 100 }, [-1] = { 66, 100 }, [0] = { 100, 100 }, [1] = { 150, 100 }, + [2] = { 200, 100 }, [3] = { 250, 100 }, [4] = { 300, 100 }, [5] = { 350, 100 }, + [6] = { 400, 100 }, +} + +function Stats.applyStage(value, stage) + local m = STAGE_MULT[math.max(-6, math.min(6, stage or 0))] + local v = math.floor(value * m[1] / m[2]) + return math.max(1, math.min(999, v)) +end + +return Stats diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua new file mode 100644 index 00000000..b6066a8e --- /dev/null +++ b/src/render/BattleTransition.lua @@ -0,0 +1,232 @@ +-- The into-battle transition (engine/battle/battle_transitions.asm): +-- one of the original's eight wipes selected by three bits, trainer +-- battle (bit 0), enemy at least 3 levels above the lead (bit 1), +-- dungeon map (bit 2): +-- %000 DoubleCircle %001 Spiral(in) %010 Circle %011 Spiral(out) +-- %100 HStripes %101 Shrink %110 VStripes %111 Split +-- Only the two circle wipes flash the screen first (only they call +-- BattleTransition_FlashScreen); the spiral runs inward unless the +-- enemy is stronger (wBattleTransitionSpiralDirection). +-- Pushed above the overworld; pops itself and runs onDone at the end. + +local BattleTransition = {} +BattleTransition.__index = BattleTransition +BattleTransition.isOpaque = false -- draws over the frozen overworld + +-- BattleTransition_FlashScreenPalettes: fade to black and back, then to +-- white and back; each palette held 2 frames, whole sequence played 3 +-- times. Positive = black overlay strength, negative = white. +local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0, + -1 / 3, -2 / 3, -1, -2 / 3, -1 / 3, 0 } +local FLASH_HOLD = 2 -- frames per palette step +local FLASH_CYCLES = 3 + +local TILE = 8 +local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles + +-- outward spiral (%011): BattleTransition_OutwardSpiral_ walks from +-- (10,10) counterclockwise (right/up/left/down), turning whenever the +-- tile on its outer side is unfilled; 120 frames x 3 fills = 360 fills +-- on linear tilemap memory. At the screen edges the walk reads (and +-- fills) adjacent WRAM, so the left column and part of the top row stay +-- unfilled until the final blackout, reproduced here by tracking those +-- cells but not drawing them. +local function outwardSpiralOrder() + local order, filled = {}, {} + local addr = 10 * COLS + 10 -- hlcoord 10,10 + local dir = 3 -- 0 up / 1 left / 2 down / 3 right + local checkOff = { [0] = -1, [1] = COLS, [2] = 1, [3] = -COLS } + local moveOff = { [0] = -COLS, [1] = -1, [2] = COLS, [3] = 1 } + for _ = 1, COLS * ROWS do + local checked = addr + checkOff[dir] + if not filled[checked] then + addr = checked + dir = (dir + 1) % 4 + else + addr = addr + moveOff[dir] + end + if not filled[addr] then + filled[addr] = true + if addr >= 0 and addr < COLS * ROWS then + order[#order + 1] = { addr % COLS, math.floor(addr / COLS) } + end + end + end + return order +end + +-- inward spiral (%001): BattleTransition_InwardSpiral starts at (0,0) +-- and walks the perimeter counterclockwise, down the left edge, right +-- along the bottom, up the right edge, left along the top, spiraling +-- in; 359 fills, the center tile is left for the final blackout +local function inwardSpiralOrder() + local order = {} + local x, y = 0, 0 + local function run(dx, dy, n) + for _ = 1, n do + order[#order + 1] = { x, y } + x, y = x + dx, y + dy + end + end + run(0, 1, 17) -- SCREEN_HEIGHT - 1 + local c = 18 + while true do + c = c + 1 + run(1, 0, c) -- right + c = c - 2 + run(0, -1, c) -- up + c = c + 1 + run(-1, 0, c) -- left + c = c - 2 + if c == 0 then break end + run(0, 1, c) -- down + end + return order +end + +-- sweep order (the Circle wipes): tiles sorted by angle from the center. +-- pokered sweeps counterclockwise starting at the right edge middle +-- (BattleTransition_HalfCircle1 runs (18,6) up over the top to (1,6); +-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)). +-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves +-- at once, so opposite arms) +local function sweepOrder(arms) + local cx, cy = COLS / 2, ROWS / 2 + local tiles = {} + for y = 0, ROWS - 1 do + for x = 0, COLS - 1 do + local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx) + if a < 0 then a = a + 2 * math.pi end + if arms == 2 then a = a % math.pi end + tiles[#tiles + 1] = { x, y, a } + end + end + table.sort(tiles, function(p, q) return p[3] < q[3] end) + return tiles +end + +local ORDERS = {} -- cached per style + +local function orderFor(style) + if not ORDERS[style] then + if style == "spiralout" then + ORDERS[style] = outwardSpiralOrder() + elseif style == "spiralin" then + ORDERS[style] = inwardSpiralOrder() + elseif style == "circle" then + ORDERS[style] = sweepOrder(1) + elseif style == "doublecircle" then + ORDERS[style] = sweepOrder(2) + end + end + return ORDERS[style] +end + +-- opts: trainer (bool), stronger (bool), dungeon (bool) +function BattleTransition.new(game, onDone, opts) + local self = setmetatable({}, BattleTransition) + self.game = game + self.onDone = onDone + self.t = 0 + opts = opts or {} + local bits = (opts.trainer and 1 or 0) + (opts.stronger and 2 or 0) + + (opts.dungeon and 4 or 0) + self.style = ({ [0] = "doublecircle", "spiralin", "circle", "spiralout", + "hstripes", "shrink", "vstripes", "split" })[bits] + -- only the circle wipes flash first (battle_transitions.asm:585,628) + self.phase = (self.style == "circle" or self.style == "doublecircle") + and "flash" or "wipe" + self.wipeLen = (self.style == "spiralin" or self.style == "spiralout" + or self.style == "circle" + or self.style == "doublecircle") and 40 or 24 + return self +end + +function BattleTransition:update(dt) + self.t = self.t + 1 + if self.phase == "flash" then + if self.t >= FLASH_CYCLES * #FLASH_STEPS * FLASH_HOLD then + self.phase = "wipe" + self.t = 0 + end + else + if self.t >= self.wipeLen + 6 then + self.game.stack:pop() + if self.onDone then self.onDone() end + end + end +end + +function BattleTransition:draw() + if self.phase == "flash" then + local step = math.floor(self.t / FLASH_HOLD) % #FLASH_STEPS + 1 + local v = FLASH_STEPS[step] + if v ~= 0 then + local shade = v > 0 and 0 or 1 + love.graphics.setColor(shade, shade, shade, math.abs(v)) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) + end + return + end + + love.graphics.setColor(0, 0, 0, 1) + local prog = math.min(1, self.t / self.wipeLen) + local style = self.style + + local order = orderFor(style) + if order then + -- tile-order wipes: spiral / circle sweeps + local n = math.floor(#order * prog) + for i = 1, n do + local c = order[i] + love.graphics.rectangle("fill", c[1] * TILE, c[2] * TILE, TILE, TILE) + end + elseif style == "hstripes" then + -- interlaced rows wipe from alternating sides + local w = math.floor(160 * prog) + for row = 0, ROWS - 1 do + local y = row * TILE + if row % 2 == 0 then + love.graphics.rectangle("fill", 0, y, w, TILE) + else + love.graphics.rectangle("fill", 160 - w, y, w, TILE) + end + end + elseif style == "vstripes" then + -- interlaced columns wipe from alternating ends + local h = math.floor(144 * prog) + for col = 0, COLS - 1 do + local x = col * TILE + if col % 2 == 0 then + love.graphics.rectangle("fill", x, 0, TILE, h) + else + love.graphics.rectangle("fill", x, 144 - h, TILE, h) + end + end + elseif style == "shrink" then + -- the image squashes toward the middle: the asm shifts rows and + -- columns inward in the same loop, so bars close from all four + -- edges at once + local h = math.floor(72 * prog) + local w = math.floor(80 * prog) + love.graphics.rectangle("fill", 0, 0, 160, h) + love.graphics.rectangle("fill", 0, 144 - h, 160, h) + love.graphics.rectangle("fill", 0, 0, w, 144) + love.graphics.rectangle("fill", 160 - w, 0, w, 144) + else -- split: the quarters tear apart from the middle; the asm shifts + -- rows and columns outward each loop, so a black cross grows from + -- the center in both axes at once + local h = math.floor(72 * prog) + local w = math.floor(80 * prog) + love.graphics.rectangle("fill", 0, 72 - h, 160, h * 2) + love.graphics.rectangle("fill", 80 - w, 0, w * 2, 144) + end + + if prog >= 1 then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return BattleTransition diff --git a/src/render/Camera.lua b/src/render/Camera.lua new file mode 100644 index 00000000..11654a7d --- /dev/null +++ b/src/render/Camera.lua @@ -0,0 +1,20 @@ +-- Camera centered on the player. At the default 160x144 view this is +-- the original framing (player sprite at screen tile (8,8) -> pixel +-- (64, 60) after the -4px sprite offset); wider/taller world-pass views +-- (window-filling survey on phones, wheel zoom-out) keep the player at +-- the same relative center. + +local Camera = {} +Camera.__index = Camera + +function Camera.new() + return setmetatable({ x = 0, y = 0 }, Camera) +end + +function Camera:follow(px, py, viewW, viewH) + viewW, viewH = viewW or 160, viewH or 144 + self.x = px - (viewW / 2 - 16) + self.y = py - (viewH / 2 - 8) +end + +return Camera diff --git a/src/render/Font.lua b/src/render/Font.lua new file mode 100644 index 00000000..6a1eaa17 --- /dev/null +++ b/src/render/Font.lua @@ -0,0 +1,116 @@ +-- Text renderer using the real extracted font sheets and charmap. +-- font.png holds glyph codes $80-$FF, font_extra.png $60-$7F (borders etc). +-- The charmap is matched greedily (longest sequence first) so multi-byte +-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs. + +local Font = {} + +local state + +function Font.load(data) + local def = data.font + local main = love.graphics.newImage(def.image) + local extra = love.graphics.newImage(def.imageExtra) + state = { + def = def, + main = main, + extra = extra, + mainQuads = {}, + extraQuads = {}, + byFirstByte = {}, + } + local function buildQuads(img, quads) + local iw, ih = img:getDimensions() + local perRow = iw / 8 + for i = 0, perRow * (ih / 8) - 1 do + quads[i] = love.graphics.newQuad((i % perRow) * 8, + math.floor(i / perRow) * 8, 8, 8, iw, ih) + end + end + buildQuads(main, state.mainQuads) + buildQuads(extra, state.extraQuads) + -- charmap comes sorted longest-first from the extractor; bucket by first + -- byte for fast greedy matching + for _, entry in ipairs(def.charmap) do + local b = entry.seq:byte(1) + state.byFirstByte[b] = state.byFirstByte[b] or {} + table.insert(state.byFirstByte[b], entry) + end +end + +-- Convert a text string into a list of glyph codes. Unknown characters +-- render as space (and are reported once). +local reported = {} +function Font.encode(text) + local codes = {} + local i = 1 + while i <= #text do + local candidates = state.byFirstByte[text:byte(i)] + local matched = false + if candidates then + for _, entry in ipairs(candidates) do + local n = #entry.seq + if text:sub(i, i + n - 1) == entry.seq then + codes[#codes + 1] = entry.code + i = i + n + matched = true + break + end + end + end + if not matched then + local ch = text:sub(i, i) + if not reported[ch] and ch:byte() >= 32 then + reported[ch] = true + require("src.core.Logger").warn("font: no glyph for %q", ch) + end + codes[#codes + 1] = 0x7F -- space + i = i + 1 + end + end + return codes +end + +function Font.drawCode(code, x, y) + local def = state.def + if code >= def.mainBase then + love.graphics.draw(state.main, state.mainQuads[code - def.mainBase], x, y) + elseif code >= def.extraBase then + love.graphics.draw(state.extra, state.extraQuads[code - def.extraBase], x, y) + end +end + +-- Draw a plain single-line string at pixel (x, y). +function Font.draw(text, x, y) + local codes = Font.encode(text) + for i, code in ipairs(codes) do + Font.drawCode(code, x + (i - 1) * 8, y) + end + return #codes * 8 +end + +-- Border glyph codes (font_extra.png, from charmap.asm $79-$7E) +Font.BORDER = { + tl = 0x79, h = 0x7A, tr = 0x7B, v = 0x7C, bl = 0x7D, br = 0x7E, +} + +-- Draw a Game Boy style bordered box in tile coordinates. +function Font.drawBox(tx, ty, tw, th) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + local B = Font.BORDER + Font.drawCode(B.tl, tx * 8, ty * 8) + Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8) + Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8) + Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8) + for i = 1, tw - 2 do + Font.drawCode(B.h, (tx + i) * 8, ty * 8) + Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8) + end + for j = 1, th - 2 do + Font.drawCode(B.v, tx * 8, (ty + j) * 8) + Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8) + end +end + +return Font diff --git a/src/render/GBCFX.lua b/src/render/GBCFX.lua new file mode 100644 index 00000000..05f49582 --- /dev/null +++ b/src/render/GBCFX.lua @@ -0,0 +1,273 @@ +-- GBC Effects post-process ("Pixel Transparency" style, see +-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder +-- applied after palette colorization and before the CRT pass: +-- 1 reflective screen: bright pixels blend toward a procedurally +-- grained warm backing (the unlit-GBC "transparent whites" look) +-- 2 + LCD subpixel grid +-- 3 + drop shadows (dark pixels float above the backing) +-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly +-- drifting light source +-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles. +-- Spec: docs/new-features.md (Custom Options / GBC FX) +-- +-- One shader for all levels: features are gated by the `level` uniform +-- (float comparisons), so cycling never recompiles. All spatial effects +-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch, +-- shadow offsets and grain stay window-size independent. + +local GBCFX = {} + +GBCFX.LABELS = { "OFF", "1", "2", "3", "4" } +GBCFX.level = 0 + +local shader -- false = unavailable (headless / no shader support) + +-- GLSL 1.20-compatible (no array initializers; wavelength terms and the +-- shadow blur are unrolled by hand). +local SHADER_SRC = [[ +extern number level; +extern number time; +extern number pixelScale; // screen pixels per GB pixel (integer fit scale) + +#define PI 3.14159265359 + +// ---- level thresholds (cumulative ladder) ---- +#define L_GRID 1.5 +#define L_SHADOW 2.5 +#define L_SUN 3.5 + +// ---- level 1: reflective backing ---- +#define BACK_BRIGHTNESS 0.48 +#define GRAIN_INTENSITY 0.065 +// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness +#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424) +#define BASE_ALPHA 0.20 +#define WHITE_EXTRA 0.75 +// front polarizer film tint +#define POLARIZER vec3(0.94, 1.0, 0.865) + +// ---- level 2: LCD grid (lcd1x style) ---- +#define BRIGHTEN_SCANLINES 16.0 +#define BRIGHTEN_LCD 4.0 + +// ---- level 3: drop shadow ---- +#define SHADOW_OFFSET 3.0 +#define SHADOW_OPACITY 0.5 + +// ---- level 4: sunlight ---- +#define GLARE_INTENSITY 0.15 +#define GLARE_SIGMA 0.25 +#define SHIMMER_INTENSITY 0.25 +// chroma amplification so the bands read on the already-desaturated, +// backing-blended image (reference applies 0.25 to raw film reflectance) +#define SHIMMER_CHROMA_GAIN 3.0 +#define SHIMMER_SPREAD 1.8 +#define LIGHT_RANGE 0.6 +#define FILM_NOISE_AMOUNT 0.5 +#define REFLECT_FLOOR 0.03 + +float hash21(vec2 p) +{ + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +// smooth value noise, ~[0,1] +float vnoise(vec2 p) +{ + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float luma(vec3 c) +{ + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc) +{ + vec4 src = Texel(tex, tc); + if (level < 0.5) { + return src * color; + } + + float ps = max(pixelScale, 1.0); + vec2 gbPix = pc / ps; // GB-pixel coordinates + vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc + vec2 gbTexel = texel * ps; // one GB pixel in tc + + // Drifting light position (normalized screen coords, upper area). + // Computed unconditionally: level 3 borrows it for shadow drift. + vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13), + 0.3 + 0.2 * sin(time * 0.07)); + + // ---- level 1: procedural backing material ---- + // flat gray + 3-octave paper grain, tinted warm + float grain = vnoise(gbPix * 0.9) * 0.5 + + vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3 + + vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2; + float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0); + vec3 back = backLum * POCKET_TINT; + + // ---- level 3: dark pixels cast a soft shadow onto the backing ---- + if (level >= L_SHADOW) { + vec2 shOff = vec2(SHADOW_OFFSET); + if (level >= L_SUN) { + // subtle drift opposite the light's wander + shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0); + } + vec2 so = tc - shOff * gbTexel; + vec2 e = gbTexel; + // 9-tap gaussian blur of the offset sample's brightness (unrolled) + float s = 0.0; + s += luma(Texel(tex, so).rgb) * 4.0; + s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0; + s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0; + s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0; + s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0; + s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0; + s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0; + s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0; + s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0; + s /= 16.0; + float dark = 1.0 - s; + // deadzone: near-white pixels (dark ~ 0) cast no shadow at all + float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY; + back = mix(back, back * 0.2, shadow); + } + + // ---- level 2: LCD subpixel grid on the lit image only ---- + vec3 lit = src.rgb; + if (level >= L_GRID) { + vec2 angle = 2.0 * PI * (gbPix - 0.25); + float yfac = (BRIGHTEN_SCANLINES + sin(angle.y)) + / (BRIGHTEN_SCANLINES + 1.0); + float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0); + lit *= yfac * xfac; + } + + // ---- level 1: brightness-proportional pixel transparency ---- + float lum = luma(src.rgb); + float a = BASE_ALPHA * lum; + // near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly + // fully transparent -- narrow smoothsteps stand in for the hard AND + float mn = min(src.r, min(src.g, src.b)); + a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn); + vec3 col = mix(lit, back, clamp(a, 0.0, 1.0)); + + // ---- level 4: sunlight (glare + rainbow QWP shimmer) ---- + float glare = 0.0; + if (level >= L_SUN) { + float aspect = love_ScreenSize.x / love_ScreenSize.y; + vec2 p = vec2(tc.x * aspect, tc.y); + vec2 lp = vec2(lightPos.x * aspect, lightPos.y); + float d = distance(p, lp); + + // specular gaussian hotspot (added after the polarizer tint: + // it reflects off the front glass, not the LCD) + glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA)); + + // quarter-wave-plate film: effective retardance (nm) grows with + // distance from the light point -> concentric interference bands; + // smooth "film thickness" noise makes the bands splotchy + float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01); + float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE)) + * (1.0 + FILM_NOISE_AMOUNT * (film - 0.5)); + float ph = 4.0 * PI * gammaEff; + + // 7 wavelength samples 400..700nm with approximate spectral RGB, + // unrolled (no const arrays in GLSL 1.20) + vec3 rb = vec3(0.0); + float cw; + cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50); + cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00); + cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40); + cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00); + cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00); + cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00); + cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00); + rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0 + + // fade with distance from the light, kill on dark pixels, + // weight by pixel color squared + float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d); + float refl = max(lum, REFLECT_FLOOR); + // luminance-preserving tint: add only the chroma of the rainbow + vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN + * src.rgb * src.rgb * refl * att; + col += shimmer * SHIMMER_INTENSITY; + } + + // front polarizer tint, then front-surface glare on top + col *= POLARIZER; + col += vec3(glare); + + return vec4(col, src.a) * color; +} +]] + +GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check + +function GBCFX.shader() + if shader == nil then + local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) + shader = ok and sh or false + end + return shader or nil +end + +function GBCFX.setLevel(level) + level = math.floor(tonumber(level) or 0) + if level < 0 then level = 0 end + if level > 4 then level = 4 end + GBCFX.level = level +end + +-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. +function GBCFX.cycle() + GBCFX.setLevel((GBCFX.level + 1) % 5) + return GBCFX.level +end + +function GBCFX.applyOptions(opts) + GBCFX.setLevel(opts and opts.gbcfx or 0) +end + +function GBCFX.levelLabel(level) + return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF" +end + +function GBCFX.active() + return GBCFX.level > 0 and GBCFX.shader() ~= nil +end + +-- Draw `canvas` fullscreen through the GBC FX shader into the current +-- render target (or plain if the shader is unavailable). pixelScale is +-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets +-- stay window-size independent. +function GBCFX.present(canvas, pixelScale) + local sh = GBCFX.shader() + if not sh or GBCFX.level <= 0 then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, 0, 0) + return + end + local t = 0 + if love.timer and love.timer.getTime then + t = love.timer.getTime() + end + sh:send("level", GBCFX.level) + sh:send("time", t) + sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1))) + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, 0, 0) + love.graphics.setShader() +end + +return GBCFX diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua new file mode 100644 index 00000000..372b3d00 --- /dev/null +++ b/src/render/HudTiles.lua @@ -0,0 +1,77 @@ +-- In-battle HUD tiles, shared by the battle screen and the status +-- screen: pokered overlays the $62-$7F font area with the HP bar / +-- status sheet (font_battle_extra -> $62) and the HUD line tiles +-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73). + +local HudTiles = {} + +local tiles +function HudTiles.tile(code, x, y, tint) + if not tiles then + tiles = {} + local function add(path, base) + local ok, img = pcall(love.graphics.newImage, path) + if not ok then return end + local iw, ih = img:getDimensions() + local per = iw / 8 + for i = 0, per * (ih / 8) - 1 do + tiles[base + i] = { + img = img, + quad = love.graphics.newQuad((i % per) * 8, + math.floor(i / per) * 8, 8, 8, iw, ih), + } + end + end + add("assets/generated/battle/font_battle_extra.png", 0x62) + add("assets/generated/battle/battle_hud_1.png", 0x6D) -- overrides + add("assets/generated/battle/battle_hud_2.png", 0x73) + add("assets/generated/battle/battle_hud_3.png", 0x76) + end + local t = tiles[code] + if not t then return end + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(tint or { 1, 1, 1, 1 }) + love.graphics.draw(t.img, t.quad, x, y) + love.graphics.setColor(r, g, b, a) +end + +-- The bar's right-end tile follows wHPBarType (DrawHPBar's "Right" +-- branch): only type 1 -- the player's in-battle bar and the status +-- screen -- gets the double-bar $6D; the enemy bar (0) and the party +-- menu (2) close with the near-blank $6C nub. +function HudTiles.capTile(barType) + return barType == 1 and 0x6D or 0x6C +end + +-- Tile HP bar (home/pokemon.asm DrawHPBar): "HP" ($71) + ":[" ($62), +-- six 8px segments ($63 empty, +n partial, $6B full), then the +-- wHPBarType right cap. A nonzero HP always shows at least a +-- one-pixel sliver. The fill is tinted with the SGB bar palettes at +-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else +-- red). +function HudTiles.drawHPBar(data, tx, ty, mon, barType) + local x, y = tx * 8, ty * 8 + HudTiles.tile(0x71, x, y) + HudTiles.tile(0x62, x + 8, y) + local px = 0 + if mon.stats.hp > 0 and mon.hp > 0 then + px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp)) + end + local tint + local pals = data.palettes + if pals then + local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" + local c = pals.palettes[name][3] -- GB color 2 is the fill shade + -- the fill pixels are the 2/3-gray shade; divide so they land on + -- the palette color exactly (the black outline stays black) + tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), + math.min(1, c[3] / 170), 1 } + end + for i = 0, 5 do + local seg = math.min(8, math.max(0, px - i * 8)) + HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint) + end + HudTiles.tile(HudTiles.capTile(barType), x + 64, y) +end + +return HudTiles diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua new file mode 100644 index 00000000..9fad582e --- /dev/null +++ b/src/render/PaletteFX.lua @@ -0,0 +1,198 @@ +-- SGB-style colorization post-pass. The Super Game Boy colored the DMG +-- picture by assigning 4-color palettes to rectangular screen regions +-- (ATTR_BLK packets, data/sgb/sgb_packets.asm). States expose +-- sgbPalettes() returning a list of zones; the finished 160x144 frame is +-- then drawn once per zone through a shader that remaps the four DMG +-- shades to that zone's palette. +-- +-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC) +-- transforms every zone's palette at send time via effectiveColors. + +local PaletteFX = {} + +local shader -- false = unavailable (headless / no shader support) + +-- Cycle order matches OptionsMenu / hotkey 2 +PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" } +PaletteFX.MODE_LABELS = { + gbc = "GBC", og = "OG", og_inv = "OG INV", + gbc_inv = "GBC INV", classic = "CLASSIC", +} +PaletteFX.mode = "gbc" + +-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F) +PaletteFX.CLASSIC = { + { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, +} + +local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } + +function PaletteFX.shader() + if shader == nil then + local ok, sh = pcall(love.graphics.newShader, [[ + extern vec3 c0; extern vec3 c1; extern vec3 c2; extern vec3 c3; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + vec3 mapped = p.r > 0.83 ? c0 : (p.r > 0.5 ? c1 : (p.r > 0.17 ? c2 : c3)); + return vec4(mapped, p.a); + } + ]]) + shader = ok and sh or false + end + return shader or nil +end + +-- Shade-remap variant that also keys shade 0 (DMG white / lightest gray) +-- to transparent -- the GB OBJ-to-BG priority trick. Tilt mode's upright +-- pass uses it for tall-grass feet overdraw: the patch must be colorized +-- to match the ground grass it hides, yet let the sprite show through the +-- grass tile's white gaps. The flat path gets this from TileRenderer's +-- color-0 key plus the whole-canvas zone colorization at blit time; the +-- upright canvas is composited with no zone pass, so the two are fused +-- into one shader here. Same c0..c3 uniforms as shader(), so sendColors +-- feeds it identically. +local keyedShader -- false = unavailable (headless / no shader support) + +function PaletteFX.keyedShader() + if keyedShader == nil then + local ok, sh = pcall(love.graphics.newShader, [[ + extern vec3 c0; extern vec3 c1; extern vec3 c2; extern vec3 c3; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + vec3 mapped = p.r > 0.83 ? c0 : (p.r > 0.5 ? c1 : (p.r > 0.17 ? c2 : c3)); + float a = (p.r > 0.83 && p.g > 0.83 && p.b > 0.83) ? 0.0 : p.a; + return vec4(mapped, a); + } + ]]) + keyedShader = ok and sh or false + end + return keyedShader or nil +end + +-- ATTR_BLK inclusive tile rect -> pixel-space zone +function PaletteFX.zone(colors, tx1, ty1, tx2, ty2) + if not colors then return nil end + return { colors = colors, x = tx1 * 8, y = ty1 * 8, + w = (tx2 - tx1 + 1) * 8, h = (ty2 - ty1 + 1) * 8 } +end + +function PaletteFX.whole(colors) + return PaletteFX.zone(colors, 0, 0, 19, 17) +end + +-- named palette from data/generated/palettes.lua (nil on stale builds) +function PaletteFX.pal(data, name) + local p = data.palettes + return p and p.palettes[name] or nil +end + +-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns. +-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species +-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a +-- Transformed mon's pic is tinted gray, not the copied species' own +-- SGB color). +function PaletteFX.monPal(data, species, transformed) + local p = data.palettes + if not p then return nil end + if transformed then return p.palettes.GRAYMON end + return p.palettes[p.pokemon[species] or "MEWMON"] +end + +-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar +function PaletteFX.barPalName(hp, maxHp) + local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 + if hp > 0 and px < 1 then px = 1 end + return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" +end + +-- convenience: a single whole-screen zone for a named palette +function PaletteFX.wholeNamed(data, name) + local c = PaletteFX.pal(data, name) + return c and { PaletteFX.whole(c) } or nil +end + +-- The four DMG grays the extracted art uses (255/170/85/0), as a +-- palette-shaped table -- shade index 0 (lightest) first, like the SGB +-- palettes in data/generated/palettes.lua. +PaletteFX.GRAYS = { { 255, 255, 255 }, { 170, 170, 170 }, + { 85, 85, 85 }, { 0, 0, 0 } } + +-- Permute a 4-color palette through a BGP-style shade map +-- (map[i] = the shade color index i displays as, i = 0..3). Emulates +-- pokered's SetAnimationBGPalette / AnimationFlashScreen* writes to +-- rBGP composed with the SGB colorization: the SGB colors the remapped +-- DMG shade, so a screen region shows palette[map[shade]]. +function PaletteFX.permute(colors, map) + if not map then return colors end + return { colors[map[0] + 1], colors[map[1] + 1], + colors[map[2] + 1], colors[map[3] + 1] } +end + +function PaletteFX.setMode(mode) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then + PaletteFX.mode = mode + return + end + end + PaletteFX.mode = "gbc" +end + +function PaletteFX.cycleMode() + local cur = PaletteFX.mode or "gbc" + local idx = 1 + for i, m in ipairs(PaletteFX.MODES) do + if m == cur then idx = i; break end + end + PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1] + return PaletteFX.mode +end + +function PaletteFX.applyOptions(opts) + PaletteFX.setMode(opts and opts.colors or "gbc") +end + +function PaletteFX.modeLabel(mode) + return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC" +end + +-- When a state exposes no SGB zones but COLORS needs a forced palette +-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap +-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas). +function PaletteFX.ensureZones(zones) + if zones and zones[1] then return zones end + local mode = PaletteFX.mode or "gbc" + if mode == "og" or mode == "og_inv" or mode == "classic" then + return { PaletteFX.whole(PaletteFX.GRAYS) } + end + return zones +end + +-- Transform a 4-color palette for the active COLORS display mode. +function PaletteFX.effectiveColors(c) + if not c then return nil end + local mode = PaletteFX.mode or "gbc" + if mode == "og" then + return PaletteFX.GRAYS + elseif mode == "og_inv" then + return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) + elseif mode == "classic" then + return PaletteFX.CLASSIC + elseif mode == "gbc_inv" then + return PaletteFX.permute(c, INV_MAP) + end + return c +end + +-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after +-- applying the active COLORS display mode +function PaletteFX.sendColors(shader, c) + c = PaletteFX.effectiveColors(c) + if not c then return end + shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) + shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 }) + shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) + shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) +end + +return PaletteFX diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua new file mode 100644 index 00000000..3e16223a --- /dev/null +++ b/src/render/Renderer.lua @@ -0,0 +1,350 @@ +-- Two-pass renderer. The UI pass is the classic 160x144 Game Boy canvas +-- drawn at the integer window fit scale S, letterboxed in the window. +-- The world pass (overworld survey zoom) is a variable-size canvas that +-- fills the *entire* window at the effective integer scale s', so black +-- letterbox voids become more map, not empty bars. Both use nearest- +-- neighbor filtering. +-- Spec: docs/new-features.md (survey zoom) + +local Zoom = require("src.render.Zoom") +local Tilt = require("src.render.Tilt") + +local Renderer = {} + +Renderer.WIDTH = 160 +Renderer.HEIGHT = 144 + +-- Tilt mode: the upright billboard canvas is grown by this many world +-- pixels on every side beyond the ground world view, so a structure or +-- sprite standing near a view edge still draws in full instead of being +-- clipped where the ground canvas ends (a receding tree wall at the top of +-- the view rises above row 0; a fence at the bottom-left drops below/left). +-- endFrame composites the padded canvas back with a matching offset. +Renderer.UPRIGHT_MARGIN = 160 + +function Renderer:init() + self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) + self.canvas:setFilter("nearest", "nearest") + self.worldCanvas = nil + self.worldActive = false + -- tilt mode only: a transparent overlay canvas the size of the world + -- canvas that receives the upright billboard pass (sprites + standing + -- FX, drawn at their projected ground anchors). It composites flat over + -- the projected ground in endFrame; never touched while tilt is off. + self.uprightCanvas = nil + self.uprightActive = false +end + +-- integer scale that fits the GB UI viewport in the window +function Renderer:fitScale() + local ww, wh = love.graphics.getDimensions() + return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT))) +end + +-- world-pass canvas size in world pixels: enough to fill the window at s'. +-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so +-- the projected ground plane still covers the whole window with no +-- background peeking at the receded top/bottom corners; flat mode returns +-- exactly today's size (growth factor is 1 when tilt is inactive). +function Renderer:worldViewSize() + local ww, wh = love.graphics.getDimensions() + local s = Zoom.scale(self:fitScale()) + local vw, vh = Zoom.fillViewSize(s, ww, wh) + if Tilt.active() then + local g = Tilt.viewGrowth() + vw, vh = math.ceil(vw * g), math.ceil(vh * g) + end + return vw, vh +end + +-- transparent: the world pass shows through (UI pass draws overlays only) +function Renderer:beginFrame(transparent) + self.worldActive = false + self.uprightActive = false + love.graphics.setCanvas(self.canvas) + if transparent then + love.graphics.clear(0, 0, 0, 0) + else + love.graphics.clear(1, 1, 1, 1) + end +end + +function Renderer:beginWorldPass() + local vw, vh = self:worldViewSize() + if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw + or self.worldCanvas:getHeight() ~= vh then + self.worldCanvas = love.graphics.newCanvas(vw, vh) + self.worldCanvas:setFilter("nearest", "nearest") + end + self.worldActive = true + love.graphics.setCanvas(self.worldCanvas) + love.graphics.clear(1, 1, 1, 1) +end + +function Renderer:endWorldPass() + love.graphics.setCanvas(self.canvas) +end + +-- Tilt mode's upright pass: standing things (sprites, tall-grass feet +-- overdraw, screen-anchored FX) draw here instead of into the ground +-- world canvas, each already projected to its ground anchor and colorized +-- with its map's SGB palette (see OverworldController:billboard). The +-- canvas is transparent so the projected ground shows through the gaps; +-- endFrame blits it flat over the projected ground. Sized/filtered like +-- the world canvas but kept separate so the ground can be projected as a +-- plane while these stay upright. Only entered while Tilt.active(). +function Renderer:beginUprightPass() + local vw, vh = self:worldViewSize() + local M = self.UPRIGHT_MARGIN + local cw, ch = vw + 2 * M, vh + 2 * M + if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw + or self.uprightCanvas:getHeight() ~= ch then + self.uprightCanvas = love.graphics.newCanvas(cw, ch) + self.uprightCanvas:setFilter("nearest", "nearest") + end + self.uprightActive = true + love.graphics.setCanvas(self.uprightCanvas) + love.graphics.clear(0, 0, 0, 0) + -- shift the whole pass into the padded canvas so billboards keep drawing + -- in flat world-canvas coordinates (0..vw, 0..vh) while the margin catches + -- anything that overhangs an edge; endFrame undoes it with the same offset + love.graphics.push() + love.graphics.translate(M, M) +end + +-- return to the ground world canvas (the world pass owns it until draw() +-- calls endWorldPass) +function Renderer:endUprightPass() + love.graphics.pop() + love.graphics.setCanvas(self.worldCanvas) +end + +-- Perspective mesh shader for tilt mode. The mesh already carries CPU- +-- projected 2D corner positions (from Tilt.groundPoint), so the vertex +-- stage does no projection; instead it passes each corner's depthScale as +-- the per-vertex "q" and pre-multiplies the texture coords by it. The +-- fragment divides back, which reconstructs perspective-correct texture +-- interpolation across the whole quad (no affine-warp seams) using the +-- exact same projection the billboards will anchor to. false = headless / +-- no shader support, in which case the renderer stays on the flat blit. +local TILT_SHADER = [[ + varying float vScale; +#ifdef VERTEX + attribute float VertexScale; + vec4 position(mat4 transform_projection, vec4 vertex_position) { + vScale = VertexScale; + VaryingTexCoord = vec4(VertexTexCoord.xy * VertexScale, 0.0, 1.0); + return transform_projection * vertex_position; + } +#endif +#ifdef PIXEL + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + return Texel(tex, tc / vScale) * color; + } +#endif +]] + +function Renderer:tiltShader() + if self._tiltShader == nil then + local ok, sh = pcall(love.graphics.newShader, TILT_SHADER) + self._tiltShader = ok and sh or false + end + return self._tiltShader or nil +end + +-- Dynamic 4-vertex ground quad; positions/depthScale are refreshed each +-- frame from Tilt.meshCorners. The custom VertexScale attribute rides the +-- perspective "q" through to the shader above. +function Renderer:tiltMesh() + if self._tiltMesh == nil then + local format = { + { "VertexPosition", "float", 2 }, + { "VertexTexCoord", "float", 2 }, + { "VertexScale", "float", 1 }, + } + local ok, mesh = pcall(love.graphics.newMesh, format, 4, "fan", "dynamic") + self._tiltMesh = ok and mesh or false + end + return self._tiltMesh or nil +end + +-- Draw the world pass through the tilt projection. Two steps: (1) a +-- canvas-to-canvas palette pre-pass that bakes the SGB world zones into a +-- colorized ground canvas in flat space (a perspective transform breaks +-- the rectangular scissors endFrame normally uses), then (2) project that +-- canvas onto the tilted plane via the perspective mesh, scaled/centred +-- exactly like the flat world blit. `target` is the canvas to project +-- into (nil = default framebuffer; presentCanvas when CRT is on). +-- Returns true on success; false (no shader/mesh) tells endFrame to fall +-- back to the flat blit unchanged. +function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target) + local shader = self:tiltShader() + local mesh = self:tiltMesh() + if not (shader and mesh) then return false end + local PaletteFX = require("src.render.PaletteFX") + local wvw = self.worldCanvas:getWidth() + local wvh = self.worldCanvas:getHeight() + + -- colorized ground canvas, resized to match the world canvas. Linear + -- sampling softens the pixel shimmer the perspective warp would cause + -- (the flat path keeps nearest). TODO(tilt): optionally render this at + -- 2x for extra crispness. + if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw + or self.tiltCanvas:getHeight() ~= wvh then + self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) + self.tiltCanvas:setFilter("linear", "linear") + end + + love.graphics.setCanvas(self.tiltCanvas) + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) + local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil + if zoneShader then + love.graphics.setShader(zoneShader) + for _, z in ipairs(zoneList) do + PaletteFX.sendColors(zoneShader, z.colors) + local x, y = math.max(0, z.x), math.max(0, z.y) + local x2, y2 = math.min(wvw, z.x + z.w), math.min(wvh, z.y + z.h) + if x2 > x and y2 > y then + love.graphics.setScissor(x, y, x2 - x, y2 - y) + love.graphics.draw(self.worldCanvas, 0, 0) + end + end + love.graphics.setScissor() + love.graphics.setShader() + else + love.graphics.draw(self.worldCanvas, 0, 0) + end + + -- project onto the tilted plane into the present target (or screen) + love.graphics.setCanvas(target) + mesh:setTexture(self.tiltCanvas) + mesh:setVertices(Tilt.meshCorners(wvw, wvh)) + love.graphics.push() + love.graphics.translate(wox, woy) + love.graphics.scale(s, s) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setShader(shader) + love.graphics.draw(mesh) + love.graphics.setShader() + love.graphics.pop() + return true +end + +-- clamp a scissor rect to the viewport box +local function scissorClamped(x, y, w, h, ox, oy, vpw, vph) + local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph) + x, y = math.max(x, ox), math.max(y, oy) + if x2 <= x or y2 <= y then return false end + love.graphics.setScissor(x, y, x2 - x, y2 - y) + return true +end + +-- zones: optional list of SGB palette regions (see PaletteFX) in +-- 160x144 UI space, applied to the UI pass. worldZones: optional +-- regions in world-canvas pixels (overworld survey zoom colors each +-- visible map area separately), applied to the world pass; the world +-- pass falls back to the UI zones when absent. Each zone is drawn +-- scissored through the shade-remap shader, later zones on top. +-- When GBC FX is active the composite is drawn into presentCanvas and +-- presented through the GBC FX shader as a final pass. +function Renderer:endFrame(zones, worldZones) + love.graphics.setCanvas() + local ww, wh = love.graphics.getDimensions() + local S = self:fitScale() + local vpw, vph = self.WIDTH * S, self.HEIGHT * S + local ox = math.floor((ww - vpw) / 2) + local oy = math.floor((wh - vph) / 2) + local PaletteFX = require("src.render.PaletteFX") + local GBCFX = require("src.render.GBCFX") + -- Forced mono/Classic modes still need a whole-screen zone when a state + -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. + zones = PaletteFX.ensureZones(zones) + if worldZones then worldZones = PaletteFX.ensureZones(worldZones) end + + local needPresent = GBCFX.active() + local present = nil + if needPresent then + if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww + or self.presentCanvas:getHeight() ~= wh then + self.presentCanvas = love.graphics.newCanvas(ww, wh) + self.presentCanvas:setFilter("linear", "linear") + end + present = self.presentCanvas + love.graphics.setCanvas(present) + end + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setColor(1, 1, 1, 1) + + -- blit `canvas` at integer `scale` into origin (bx, by), scissored to + -- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone + -- coords (canvas-space) into screen pixels. + local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) + local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil + if not shader then + love.graphics.setScissor(boxX, boxY, boxW, boxH) + love.graphics.draw(canvas, bx, by, 0, scale, scale) + love.graphics.setScissor() + return + end + love.graphics.setShader(shader) + for _, z in ipairs(zoneList) do + PaletteFX.sendColors(shader, z.colors) + if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale, + z.w * zoneScale, z.h * zoneScale, + boxX, boxY, boxW, boxH) then + love.graphics.draw(canvas, bx, by, 0, scale, scale) + end + end + love.graphics.setScissor() + love.graphics.setShader() + end + + if self.worldActive then + local s = Zoom.scale(S) + local wvw = self.worldCanvas:getWidth() + local wvh = self.worldCanvas:getHeight() + local wox = math.floor((ww - wvw * s) / 2) + local woy = math.floor((wh - wvh * s) / 2) + -- Tilt mode projects the ground world pass through the perspective mesh + -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone + -- scissoring here). drawTiltedWorld returns false when tilt is off or + -- projection is unavailable (headless / no shader); then the ground + -- falls through to the flat blit, keeping the flat frame byte-for-byte + -- identical to today. + local projected = + Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present) + if not projected then + if worldZones then + blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh) + else + blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh) + end + end + -- Composite the tilt upright pass over the ground (projected or, in the + -- rare no-shader fallback, flat). It already carries its billboards' + -- projected positions and per-sprite SGB colorization on a transparent + -- canvas, so it just needs the same centred integer-scale blit the flat + -- world pass uses -- no zone scissoring. uprightActive is only ever + -- set in tilt mode, so flat frames skip this and stay identical. + if self.uprightActive then + local M = self.UPRIGHT_MARGIN + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setScissor(0, 0, ww, wh) + love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s) + love.graphics.setScissor() + end + end + -- UI stays in the classic centered GB letterbox + blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) + + if present then + love.graphics.setCanvas() + GBCFX.present(present, S) + end + self.worldActive = false + self.uprightActive = false +end + +return Renderer diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua new file mode 100644 index 00000000..9c5d089f --- /dev/null +++ b/src/render/SpriteRenderer.lua @@ -0,0 +1,61 @@ +-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16 +-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm). +-- Right-facing frames are horizontal flips of the left frames. +-- Sprites draw 4px above their cell, like the GB engine. + +local SpriteRenderer = {} +SpriteRenderer.__index = SpriteRenderer + +local imageCache = {} + +local function getImage(path) + if not imageCache[path] then + imageCache[path] = love.graphics.newImage(path) + end + return imageCache[path] +end + +local STAND = { down = 0, up = 1, left = 2, right = 2 } +local WALK = { down = 3, up = 4, left = 5, right = 5 } + +function SpriteRenderer.new(spriteDef) + local self = setmetatable({}, SpriteRenderer) + self.def = spriteDef + self.image = getImage(spriteDef.image) + local iw, ih = self.image:getDimensions() + self.frames = {} + for f = 0, spriteDef.frames - 1 do + self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih) + end + return self +end + +-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate +-- steps mirror the walk frame for up/down (GB uses OAM flip for this). +function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) + local x = math.floor(px - camX) + local y = math.floor(py - camY) - 4 + -- single-frame sprites (item balls, fossils...) have one fixed pose; + -- still 3-frame sprites turn to face (the nurse at her machine, + -- facePlayer on STAY NPCs) but never show walk frames + if self.def.frames <= 1 then + love.graphics.draw(self.image, self.frames[0], x, y) + return + end + local frame = (self.def.walker and walkPhase == 1) + and WALK[facing] or STAND[facing] + local flip = false + if facing == "right" then + flip = true + elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then + flip = true + end + local quad = self.frames[frame] or self.frames[0] + if flip then + love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1) + else + love.graphics.draw(self.image, quad, x, y) + end +end + +return SpriteRenderer diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua new file mode 100644 index 00000000..a7b9beb2 --- /dev/null +++ b/src/render/TextBox.lua @@ -0,0 +1,220 @@ +-- The lower dialogue box: bordered 20x6-tile window, typewriter effect, +-- two visible text lines, A to advance. +-- +-- Text markers (from the extractor): \n = second line, \v = scroll one +-- line up, \f = page break (wait for A, clear). {PLAYER}/{RIVAL} etc. are +-- substituted before display. Pushed on the state stack; pops itself when +-- the text is exhausted and A is pressed, then calls onDone. + +local Font = require("src.render.Font") + +local TextBox = {} +TextBox.__index = TextBox + +local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 +local LINE1_Y, LINE2_Y = (BOX_TY + 2) * 8, (BOX_TY + 4) * 8 +local TEXT_X = 8 +local MAX_COLS = 18 + +-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops +-- up over the still-visible text (YesNoChoicePokeCenter and friends); +-- the box then closes and choice(yes) runs instead of onDone. +-- opts.defaultNo starts the cursor on NO. +-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like +-- _UsedStrengthText) never wait for a button: once the last page has +-- typed out, auto.sound() runs (returning an audio source blocks like +-- WaitForSoundToFinish; nil headless), then auto.delay frames pass +-- (default 3, Delay3) and the box pops itself + calls onDone. No +-- blinking cursor, no Press_AB beep. +function TextBox.new(game, text, onDone, opts) + local self = setmetatable({}, TextBox) + self.game = game + self.onDone = onDone + self.choice = opts and opts.choice + self.defaultNo = opts and opts.defaultNo + self.auto = opts and opts.auto + text = TextBox.substitute(game, text) + self.pages = TextBox.paginate(text) + self.pageIndex = 1 + self.lineIndex = 1 + self.charIndex = 0 + self.shown = {} -- visible lines (max 2), each a list of glyph codes + self.waiting = false + self.done = false + self.blink = 0 + self:beginLine() + return self +end + +function TextBox.substitute(game, text) + local save = game.save + text = text:gsub("{PLAYER}", save.player.name or "RED") + text = text:gsub("{RIVAL}", save.player.rival or "BLUE") + -- wStringBuffer: give_item copies the item name here, like GiveItem -> + -- CopyToStringBuffer (home/give.asm); "received item!" texts read it + -- (staying set afterwards mirrors pokered's stale-buffer semantics) + if game.stringBuffer then + text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer) + end + text = text:gsub("{[%w_:]+}", "") -- other runtime tokens: drop visibly-empty + return text +end + +-- Split marked-up text into pages of lines. \v-scrolled lines become +-- additional lines on the same page (the box scrolls them). +function TextBox.paginate(text) + local pages = {} + for pageText in (text .. "\f"):gmatch("(.-)\f") do + if pageText ~= "" then + local lines = {} + for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do + local line = chunk + -- wrap long lines defensively (the source rarely needs it) + while #line > MAX_COLS do + local cut = MAX_COLS + for i = MAX_COLS, 1, -1 do + if line:sub(i, i) == " " then cut = i break end + end + table.insert(lines, line:sub(1, cut)) + line = line:sub(cut + 1) + end + table.insert(lines, line) + end + -- drop trailing empty line from the final gmatch round + if lines[#lines] == "" then table.remove(lines) end + if #lines > 0 then table.insert(pages, lines) end + end + end + if #pages == 0 then pages = { { "" } } end + return pages +end + +function TextBox:currentLine() + return self.pages[self.pageIndex][self.lineIndex] +end + +function TextBox:beginLine() + self.charIndex = 0 + self.codes = Font.encode(self:currentLine()) + if #self.shown >= 2 then + table.remove(self.shown, 1) + self.scrollPx = 8 -- pixel scroll-up (ScrollTextUpOneLine) + end + table.insert(self.shown, {}) +end + +function TextBox:update(dt) + local input = self.game.input + self.blink = (self.blink + 1) % 60 + if self.done then + if self.auto then + if not self.autoStarted then + self.autoStarted = true + self.autoSrc = self.auto.sound and self.auto.sound() or nil + self.autoTimer = 0 + end + if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then + return -- the cry is still sounding (WaitForSoundToFinish) + end + self.autoTimer = self.autoTimer + 1 + local delay = self.auto.delay or 3 + -- auto.onOverlap: fired once when the delay elapses but before the + -- box closes, so an overlay (the Pallet "!" bubble) can appear + -- while the box is still on screen; the box then lingers + -- auto.overlap more frames before popping (scripts/PalletTown.asm + -- PalletTownOakText: DelayFrames 10 then EmotionBubble over the + -- still-shown "Hey! Wait!" box). + if self.auto.onOverlap and not self.overlapFired + and self.autoTimer >= delay then + self.overlapFired = true + self.auto.onOverlap() + end + if self.autoTimer >= delay + (self.auto.overlap or 0) then + self.game.stack:pop() + if self.onDone then self.onDone() end + end + return + end + if self.choice then + if not self.choicePushed then + self.choicePushed = true + local ChoiceBox = require("src.ui.ChoiceBox") + self.game.stack:push(ChoiceBox.new(self.game, function(yes) + self.game.stack:pop() -- this text box, under the choice + self.choice(yes) + end, { defaultNo = self.defaultNo })) + end + return + end + if input:wasPressed("a") or input:wasPressed("b") then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then self.onDone() end + end + return + end + if self.waiting then + if input:wasPressed("a") or input:wasPressed("b") then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + end + return + end + -- typewriter cadence: one character every N frames, N = the OPTION + -- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B + -- prints every frame like the original's held-button fast path + local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3 + if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end + if input:isDown("a") or input:isDown("b") then delay = 1 end + self.charTimer = (self.charTimer or 0) + 1 + while self.charTimer >= delay do + self.charTimer = self.charTimer - delay + if self.charIndex < #self.codes then + self.charIndex = self.charIndex + 1 + local line = self.shown[#self.shown] + line[#line + 1] = self.codes[self.charIndex] + else + -- line finished + local page = self.pages[self.pageIndex] + if self.lineIndex < #page then + self.lineIndex = self.lineIndex + 1 + self:beginLine() + elseif self.pageIndex < #self.pages then + self.waiting = true + else + self.done = true + end + break + end + end +end + +function TextBox:draw() + Font.drawBox(BOX_TX, BOX_TY, BOX_TW, BOX_TH) + love.graphics.setColor(0, 0, 0, 1) + if self.scrollPx and self.scrollPx > 0 then + self.scrollPx = self.scrollPx - 2 + if self.scrollPx <= 0 then self.scrollPx = nil end + end + local off = self.scrollPx or 0 + local ys = { LINE1_Y, LINE2_Y } + for i, line in ipairs(self.shown) do + local y = (ys[i] or LINE2_Y) + off + for j, code in ipairs(line) do + Font.drawCode(code, TEXT_X + (j - 1) * 8, y) + end + end + if (self.waiting or (self.done and not self.choice and not self.auto)) + and self.blink < 30 then + -- page-advance cursor: glyph $EE, the blinking down arrow the original + -- prints via `ld a, "▼"` (home/text.asm) + Font.drawCode(0xEE, 18 * 8, (BOX_TY + 5) * 8 - 4) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return TextBox diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua new file mode 100644 index 00000000..7e7e259f --- /dev/null +++ b/src/render/TileRenderer.lua @@ -0,0 +1,407 @@ +-- Draws a map's tile layer: one texture atlas per tileset, 8x8 quads, +-- a single static SpriteBatch covering the map plus a border-block ring +-- (the ring plays the role of the GB border blocks around small maps). + +local TileRenderer = {} +TileRenderer.__index = TileRenderer + +local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) + +-- OVERWORLD maps fill beyond-edge space with the solid tree wall +-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, +-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), +-- not each map's own border_block, which can be grass ($0B, the +-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other +-- tilesets keep their designated border (interiors stay black/void) +local TREE_WALL_BLOCK = 0x0F +local function borderBlockFor(map) + if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end + return map.def.borderBlock +end +TileRenderer.borderBlockFor = borderBlockFor + +local imageCache = {} + +local function getImage(path) + if not imageCache[path] then + imageCache[path] = love.graphics.newImage(path) + end + return imageCache[path] +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. +-- ------------------------------------------------------------------ + +local WATER_TILE, FLOWER_TILE = 0x14, 0x03 +-- cumulative pixel offset per animation step (the rrca/rlca sequence) +local WATER_OFFSETS = { 1, 2, 3, 2, 1, 0, 7, 0 } +-- flower frame per step (wMovingBGTilesCounter2 & 3: <2 -> 1, 2, 3) +local FLOWER_FRAMES = { 1, 2, 3, 1, 1, 2, 3, 1 } + +local animFrame = 0 +function TileRenderer.tick() + animFrame = animFrame + 1 +end + +-- ------------------------------------------------------------------ +-- Spinner arrow tiles (engine/overworld/spinners.asm LoadSpinnerArrowTiles): +-- a wholly separate, contextually-triggered VRAM patch layered on top of +-- the ambient water/flower cycle above -- while wMovementFlags.BIT_SPINNING +-- is set (Gym/Rocket Hideout spinner puzzles), each forced-movement step +-- farcalls LoadSpinnerArrowTiles, which flips 4 fixed destination tile IDs +-- per tileset between the shared 'blur' graphic (gfx/overworld/spinners.2bpp, +-- SpinnerArrowAnimTiles) and the tileset's own static graphic (restore). +-- Only 2 distinct frames exist -- no continuous multi-frame cycle. +-- ------------------------------------------------------------------ + +-- data/tilesets/spinner_tiles.asm: dest tile IDs patched per tileset +TileRenderer.SPINNER_ARROW_TILES = { + GYM = { 0x3c, 0x3d, 0x4c, 0x4d }, + FACILITY = { 0x20, 0x21, 0x30, 0x31 }, +} + +-- dest tile id -> offset (in 8x8 tiles) into the SpinnerArrowAnimTiles strip, +-- taken verbatim from the `spinner SpinnerArrowAnimTiles, , ` +-- rows of data/tilesets/spinner_tiles.asm +local SPINNER_STRIP_OFFSET = { + GYM = { [0x3c] = 1, [0x3d] = 3, [0x4c] = 0, [0x4d] = 2 }, + FACILITY = { [0x20] = 0, [0x21] = 1, [0x30] = 2, [0x31] = 3 }, +} + +local spinning = false +function TileRenderer.setSpinning(active) + spinning = active +end + +-- true while the spinner arrow tiles should show the 'blur' graphic; false +-- means draw nothing extra (the static mapBatch/ringBatch tile shows +-- through, matching the asm's restore-to-original behavior). The 8-tick +-- half-period approximates one GB movement step (2px/frame); this is a +-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not +-- a cycle-accurate replication -- the port's tweened scriptMove has no +-- direct equivalent discrete step counter. +function TileRenderer.spinBlurActive() + return spinning and (math.floor(animFrame / 8) % 2 == 0) +end + +-- the 8 shifted variants of a tileset's water tile (built once per sheet) +local waterVariants = {} +local function getWaterVariants(tilesetImagePath, perRow) + if waterVariants[tilesetImagePath] ~= nil then + return waterVariants[tilesetImagePath] + end + if not (love.image and love.image.newImageData) then + waterVariants[tilesetImagePath] = false + return false + end + local id = love.image.newImageData(tilesetImagePath) + local sx = (WATER_TILE % perRow) * 8 + local sy = math.floor(WATER_TILE / perRow) * 8 + local out = {} + for o = 0, 7 do + local v = love.image.newImageData(8, 8) + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = id:getPixel(sx + x, sy + y) + v:setPixel((x + o) % 8, y, r, g, b, a) + end + end + out[o + 1] = love.graphics.newImage(v) + end + waterVariants[tilesetImagePath] = out + return out +end + +local flowerFrames +local function getFlowerFrames() + if flowerFrames ~= nil then return flowerFrames end + flowerFrames = {} + for i = 1, 3 do + local ok, img = pcall(love.graphics.newImage, + ("assets/generated/tilesets/flower%d.png"):format(i)) + if not ok then flowerFrames = false return false end + flowerFrames[i] = img + end + return flowerFrames +end + +-- the tileset's own atlas ImageData with the 4 spinner-tile slots blitted +-- over with the shared blur strip (assets/generated/tilesets/spinners.png, +-- extracted from gfx/overworld/spinners.png); cached per tileset image path +local spinnerBlurImages = {} +local spinnerStripData +local function getSpinnerBlurImage(tilesetId, tilesetImagePath, perRow) + if spinnerBlurImages[tilesetImagePath] ~= nil then + return spinnerBlurImages[tilesetImagePath] + end + if not (love.image and love.image.newImageData) then + spinnerBlurImages[tilesetImagePath] = false + return false + end + local destTiles = TileRenderer.SPINNER_ARROW_TILES[tilesetId] + local offsets = SPINNER_STRIP_OFFSET[tilesetId] + if not (destTiles and offsets) then + spinnerBlurImages[tilesetImagePath] = false + return false + end + if spinnerStripData == nil then + local ok, id = pcall(love.image.newImageData, + "assets/generated/tilesets/spinners.png") + spinnerStripData = ok and id or false + end + if not spinnerStripData then + spinnerBlurImages[tilesetImagePath] = false + return false + end + local atlas = love.image.newImageData(tilesetImagePath) + local clone = love.image.newImageData(atlas:getWidth(), atlas:getHeight()) + clone:paste(atlas, 0, 0, 0, 0, atlas:getWidth(), atlas:getHeight()) + for _, id in ipairs(destTiles) do + local sx = offsets[id] * 8 + local dx = (id % perRow) * 8 + local dy = math.floor(id / perRow) * 8 + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = spinnerStripData:getPixel(sx + x, y) + clone:setPixel(dx + x, dy + y, r, g, b, a) + end + end + end + local img = love.graphics.newImage(clone) + spinnerBlurImages[tilesetImagePath] = img + return img +end + +function TileRenderer.new(map) + local self = setmetatable({}, TileRenderer) + self.map = map + self.image = getImage(map.tileset.image) + + local iw, ih = self.image:getDimensions() + self.quads = {} + local perRow = map.tileset.tilesPerRow + for t = 0, (iw / 8) * (ih / 8) - 1 do + self.quads[t] = love.graphics.newQuad((t % perRow) * 8, + math.floor(t / perRow) * 8, 8, 8, iw, ih) + end + + local def = map.def + local wB, hB = def.width, def.height + -- two batches: the border-block ring around the map, and the map body. + -- Connected-map strips draw body-only on top of this map's ring. + local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16 + self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static") + self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static") + -- animated tiles overdraw the static batches each frame + local anim = map.tileset.animation + local animWater = anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" + local variants = animWater and getWaterVariants(map.tileset.image, perRow) + local flowers = anim == "TILEANIM_WATER_FLOWER" and getFlowerFrames() + -- Gym/Rocket-Hideout spinner-arrow tiles (see SPINNER_ARROW_TILES above); + -- only GYM/FACILITY tilesets carry these dest tile ids + local spinnerIds = TileRenderer.SPINNER_ARROW_TILES[map.tileset.id] + local spinnerSet + if spinnerIds then + spinnerSet = {} + for _, id in ipairs(spinnerIds) do spinnerSet[id] = true end + end + local water, flower, spinner = {}, {}, {} + + for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do + for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do + local inside = bx >= 0 and by >= 0 and bx < wB and by < hB + local batch = inside and self.mapBatch or self.ringBatch + local block = map.tileset.blocks[map:blockAt(bx, by) + 1] + for ty = 0, 3 do + for tx = 0, 3 do + local tile = block[ty * 4 + tx + 1] + local quad = self.quads[tile] + if quad then + batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) + end + if variants and tile == WATER_TILE then + table.insert(water, { bx * 32 + tx * 8, by * 32 + ty * 8, inside }) + elseif flowers and tile == FLOWER_TILE then + table.insert(flower, { bx * 32 + tx * 8, by * 32 + ty * 8, inside }) + elseif spinnerSet and spinnerSet[tile] then + table.insert(spinner, { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }) + end + end + end + end + end + + -- animated overdraw batches: the full set (ring + body) for the + -- current map, and a body-only set for connected-map drawing -- + -- a neighbor's water ring must never overdraw this map's tiles. + -- `quadFor`, when given, looks up a per-entry quad (used by the spinner + -- batch, whose texture is a full tileset-atlas clone rather than a + -- single-tile image like the water/flower variants). + local function animBatches(entries, image, quadFor) + if #entries == 0 then return nil, nil end + local all = love.graphics.newSpriteBatch(image, #entries, "static") + local body + for _, c in ipairs(entries) do + if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end + if c[3] then + body = body or love.graphics.newSpriteBatch(image, #entries, "static") + if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end + end + end + return all, body + end + if variants then + self.waterBatch, self.waterBodyBatch = animBatches(water, variants[1]) + self.waterVariants = self.waterBatch and variants or nil + end + if flowers then + self.flowerBatch, self.flowerBodyBatch = animBatches(flower, flowers[1]) + self.flowerFrames = self.flowerBatch and flowers or nil + end + if spinnerSet then + local blurImage = getSpinnerBlurImage(map.tileset.id, map.tileset.image, perRow) + if blurImage then + local quads = self.quads + self.spinnerBatch, self.spinnerBodyBatch = + animBatches(spinner, blurImage, function(tile) return quads[tile] end) + self.spinnerBlurImage = self.spinnerBatch and blurImage or nil + end + end + + -- a repeating 32x32 image of the border block, tiled behind + -- everything the 3-block ring doesn't cover (the survey zoom sees + -- far past the ring; interiors keep their black border this way) + pcall(function() + local border = map.tileset.blocks[borderBlockFor(map) + 1] + if not border then return end + local canvas = love.graphics.newCanvas(32, 32) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 1, 1, 1) + for ty = 0, 3 do + for tx = 0, 3 do + local quad = self.quads[border[ty * 4 + tx + 1]] + if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end + end + end + love.graphics.setCanvas() + love.graphics.pop() + local img = love.graphics.newImage(canvas:newImageData()) + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + self.borderFill = img + end) + + return self +end + +-- tile the border block across the whole view (world-aligned so it +-- meshes seamlessly with the ring batch) +function TileRenderer:drawBorderFill(camX, camY, vw, vh) + if not self.borderFill then return end + local x, y = math.floor(camX), math.floor(camY) + local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32) + love.graphics.draw(self.borderFill, quad, 0, 0) +end + +-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under +-- colors 1-3. Tall-grass overdraw needs the same rule, otherwise the +-- tile's white gaps paint opaque boxes over the sprite's feet. +local color0KeyShader -- false = unavailable +local function getColor0KeyShader() + if color0KeyShader ~= nil then return color0KeyShader or nil end + local ok, sh = pcall(love.graphics.newShader, [[ + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc) * color; + // same shade-0 cutoff PaletteFX uses (DMG white / lightest gray) + if (p.r > 0.83 && p.g > 0.83 && p.b > 0.83) p.a = 0.0; + return p; + } + ]]) + color0KeyShader = ok and sh or false + return color0KeyShader or nil +end + +-- draw a cell's bottom tile row without touching the shader (the caller +-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's +-- upright pass wraps it with a color-0-keyed palette shader instead +-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground. +function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) + local ty = cy * 2 + 1 + for i = 0, 1 do + local tx = cx * 2 + i + local quad = self.quads[self.map:tileAt(tx, ty)] + if quad then + love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) + end + end +end + +-- redraw a cell's bottom tile row (tall grass hides the lower half of +-- sprites standing in it, like the GB sprite-priority trick) +function TileRenderer:drawCellBottom(cx, cy, camX, camY) + local shader = getColor0KeyShader() + if shader then love.graphics.setShader(shader) end + self:drawCellBottomRaw(cx, cy, camX, camY) + if shader then love.graphics.setShader() end +end + +-- water/flower overdraw at the current animation step; bodyOnly skips +-- the ring positions (connected maps draw body-only) +function TileRenderer:drawAnimated(camX, camY, bodyOnly) + local waterBatch = bodyOnly and self.waterBodyBatch or self.waterBatch + local flowerBatch = bodyOnly and self.flowerBodyBatch or self.flowerBatch + local spinnerBatch = bodyOnly and self.spinnerBodyBatch or self.spinnerBatch + if not (waterBatch or flowerBatch or spinnerBatch) then return end + local i = (math.floor(animFrame / 20) % 8) + 1 + local x, y = -math.floor(camX), -math.floor(camY) + if waterBatch then + waterBatch:setTexture(self.waterVariants[WATER_OFFSETS[i] + 1]) + love.graphics.draw(waterBatch, x, y) + end + if flowerBatch then + flowerBatch:setTexture(self.flowerFrames[FLOWER_FRAMES[i]]) + love.graphics.draw(flowerBatch, x, y) + end + -- spinner arrow tiles (engine/overworld/spinners.asm): only 2 frames + -- (blur / restore-to-static), gated on spinBlurActive() rather than the + -- free-running water/flower cycle above -- when false, draw nothing so + -- the already-static mapBatch/ringBatch tile shows through unchanged + if spinnerBatch and TileRenderer.spinBlurActive() then + love.graphics.draw(spinnerBatch, x, y) + end +end + +function TileRenderer:draw(camX, camY) + love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY)) + love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) + self:drawAnimated(camX, camY) +end + +-- body only, for connected-map strips +function TileRenderer:drawMapOnly(camX, camY) + love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) + self:drawAnimated(camX, camY, true) +end + +-- rebuild after a block change (Cut trees) +function TileRenderer:rebuild() + local fresh = TileRenderer.new(self.map) + self.ringBatch = fresh.ringBatch + self.mapBatch = fresh.mapBatch + self.waterBatch = fresh.waterBatch + self.waterBodyBatch = fresh.waterBodyBatch + self.waterVariants = fresh.waterVariants + self.flowerBatch = fresh.flowerBatch + self.flowerBodyBatch = fresh.flowerBodyBatch + self.flowerFrames = fresh.flowerFrames + self.spinnerBatch = fresh.spinnerBatch + self.spinnerBodyBatch = fresh.spinnerBodyBatch + self.spinnerBlurImage = fresh.spinnerBlurImage + self.borderFill = fresh.borderFill +end + +return TileRenderer diff --git a/src/render/Tilt.lua b/src/render/Tilt.lua new file mode 100644 index 00000000..e7cc68fd --- /dev/null +++ b/src/render/Tilt.lua @@ -0,0 +1,155 @@ +-- Overworld tilt mode: a cycleable, purely presentational perspective +-- tilt for the free-roam overworld. The flat world canvas is treated as +-- a ground plane, rotated about the horizontal axis through the viewport +-- centre and viewed through a perspective camera, so rows above centre +-- recede/shrink and rows below come closer (the HD-2D "diorama" look). +-- Like survey zoom this lives entirely in the draw path -- zero effect +-- on collision, movement, triggers, scripts -- and is persisted via +-- save.options.tilt (OFF / 15 / 35 / 50). +-- +-- Spec: docs/new-features.md (tilt mode) + +local Zoom = require("src.render.Zoom") + +local Tilt = {} + +-- Discrete tilt angles in degrees (index 0 is off). Cycle: off→15→35→50→off. +Tilt.ANGLES_DEG = { 0, 15, 35, 50 } +Tilt.ANGLE_LABELS = { "OFF", "15", "35", "50" } + +-- Runtime state. `level` is the discrete option (0=off .. 3=50°); +-- `angle` is the live tweened tilt in radians; `from`/`goal`/`t` drive +-- the ease between any two levels (including off). +Tilt.level = 0 +Tilt.angle = 0 +Tilt.from = 0 +Tilt.goal = 0 +Tilt.t = 1 +-- Compatibility: TARGET_ANGLE is the current goal; enabled mirrors level > 0. +Tilt.TARGET_ANGLE = 0 +Tilt.enabled = false + +Tilt.TWEEN_TIME = 0.25 +Tilt.FOCAL = 1.0 +Tilt.VIEW_MARGIN = 0.35 + +local function ease(t) + return t * t * (3 - 2 * t) +end + +local function goalFor(level) + return math.rad(Tilt.ANGLES_DEG[level + 1] or 0) +end + +function Tilt.setLevel(level) + level = math.floor(tonumber(level) or 0) + if level < 0 then level = 0 end + if level > 3 then level = 3 end + local goal = goalFor(level) + if goal ~= Tilt.goal or level ~= Tilt.level then + Tilt.from = Tilt.angle + Tilt.goal = goal + Tilt.t = 0 + end + Tilt.level = level + Tilt.TARGET_ANGLE = goal + Tilt.enabled = level > 0 +end + +-- Advance OFF → 15 → 35 → 50 → OFF. Returns the new level. +function Tilt.cycle() + Tilt.setLevel((Tilt.level + 1) % 4) + return Tilt.level +end + +-- Legacy name: one cycle step (same as cycle). +function Tilt.toggle() + return Tilt.cycle() +end + +function Tilt.reset() + Tilt.level = 0 + Tilt.angle = 0 + Tilt.from = 0 + Tilt.goal = 0 + Tilt.t = 1 + Tilt.TARGET_ANGLE = 0 + Tilt.enabled = false +end + +function Tilt.applyOptions(opts) + local level = math.floor(tonumber(opts and opts.tilt) or 0) + if level < 0 then level = 0 end + if level > 3 then level = 3 end + Tilt.level = level + Tilt.goal = goalFor(level) + Tilt.from = Tilt.goal + Tilt.angle = Tilt.goal + Tilt.t = 1 + Tilt.TARGET_ANGLE = Tilt.goal + Tilt.enabled = level > 0 +end + +function Tilt.levelLabel(level) + return Tilt.ANGLE_LABELS[(level or Tilt.level) + 1] or "OFF" +end + +-- Ease angle from `from` toward `goal` over TWEEN_TIME. +function Tilt.update(dt) + if Tilt.t < 1 then + Tilt.t = math.min(1, Tilt.t + dt / Tilt.TWEEN_TIME) + local e = ease(Tilt.t) + Tilt.angle = Tilt.from + (Tilt.goal - Tilt.from) * e + else + Tilt.angle = Tilt.goal + end + Tilt.TARGET_ANGLE = Tilt.goal + Tilt.enabled = Tilt.level > 0 +end + +-- true while tilt is on *or* still tweening -- i.e. whenever the renderer +-- must take the perspective path rather than the flat blit +function Tilt.active() + return Tilt.level > 0 or Tilt.angle > 0 +end + +function Tilt.gateOK(top, overworld) + return Zoom.gateOK(top, overworld) +end + +function Tilt.groundPoint(cx, cy, vw, vh) + local a = Tilt.angle + if a <= 0 then return cx, cy, 1 end + local u = cx - vw * 0.5 + local w = cy - vh * 0.5 + local d = Tilt.FOCAL * vh + local scale = d / (d - w * math.sin(a)) + local sx = vw * 0.5 + u * scale + local sy = vh * 0.5 + w * math.cos(a) * scale + return sx, sy, scale +end + +function Tilt.viewGrowth() + local a = Tilt.angle + if a <= 0 then return 1 end + local topScale = 1 / (1 + 0.5 * math.sin(a) / Tilt.FOCAL) + local base = 1 / (math.cos(a) * topScale) + return base + Tilt.VIEW_MARGIN * (base - 1) +end + +function Tilt.meshCorners(vw, vh) + local corners = { + { 0, 0, 0, 0 }, + { vw, 0, 1, 0 }, + { vw, vh, 1, 1 }, + { 0, vh, 0, 1 }, + } + local out = {} + for i, c in ipairs(corners) do + local sx, sy, scale = Tilt.groundPoint(c[1], c[2], vw, vh) + out[i] = { sx, sy, c[3], c[4], scale } + end + return out +end + +return Tilt diff --git a/src/render/Transition.lua b/src/render/Transition.lua new file mode 100644 index 00000000..a5d4a073 --- /dev/null +++ b/src/render/Transition.lua @@ -0,0 +1,68 @@ +-- Screen fade used for warps: fade out, run a callback (map switch), fade in. +-- Pushed on the state stack above the overworld. + +local Transition = {} +Transition.__index = Transition + +local FRAMES = 12 + +function Transition.new(game, onMidpoint, onDone) + local self = setmetatable({}, Transition) + self.game = game + self.onMidpoint = onMidpoint + self.onDone = onDone + self.t = 0 + self.phase = "out" + return self +end + +function Transition:update(dt) + self.t = self.t + 1 + if self.t >= FRAMES then + self.t = 0 + if self.phase == "out" then + self.phase = "in" + if self.onMidpoint then self.onMidpoint() end + else + self.game.stack:pop() + if self.onDone then self.onDone() end + end + end +end + +function Transition:draw() + local alpha = self.t / FRAMES + if self.phase == "in" then alpha = 1 - alpha end + love.graphics.setColor(0, 0, 0, alpha) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) +end + +-- GBPalWhiteOutWithDelay3 (home/palettes.asm): the field moves that close +-- the party menu (start_sub_menus.asm .goBackToMap paths) white out the +-- palettes, and they stay white through Delay3 + the screen-tile restore +-- until CloseTextDisplay's LoadGBPal -- a ~7-frame solid-white blink. +-- Instant white, hold, instant restore (a palette write, not a fade). +local WhiteFlash = {} +WhiteFlash.__index = WhiteFlash +WhiteFlash.isOpaque = true + +function Transition.whiteFlash(game, frames, onDone) + return setmetatable({ game = game, frames = frames or 7, + onDone = onDone, t = 0 }, WhiteFlash) +end + +function WhiteFlash:update(dt) + self.t = self.t + 1 + if self.t >= self.frames then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +function WhiteFlash:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) +end + +return Transition diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua new file mode 100644 index 00000000..4e76a11f --- /dev/null +++ b/src/render/Zoom.lua @@ -0,0 +1,48 @@ +-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped +-- by the mouse wheel. Stored as an offset from the window fit scale S +-- so a resize keeps the relative zoom. Session-only; never saved. +-- Spec: docs/new-features.md (survey zoom) + +local Zoom = {} + +Zoom.offset = 0 + +-- effective integer scale s' in [1, 2*S] +function Zoom.scale(S) + return math.max(1, math.min(2 * S, S + Zoom.offset)) +end + +function Zoom.step(delta, S) + Zoom.offset = Zoom.offset + delta + if S + Zoom.offset < 1 then Zoom.offset = 1 - S end + if S + Zoom.offset > 2 * S then Zoom.offset = S end +end + +function Zoom.reset() + Zoom.offset = 0 +end + +-- world pixels covered by a w x h letterbox viewport at fit scale S +-- (legacy GB-framed size; prefer fillViewSize for the live world pass) +function Zoom.viewSize(S, w, h) + local s = Zoom.scale(S) + return math.ceil(w * S / s), math.ceil(h * S / s) +end + +-- world pixels needed to fill a ww x wh window at the current zoom scale +-- (fills letterbox "black voids" with more map, phones, tall windows) +function Zoom.fillViewSize(s, ww, wh) + return math.ceil(ww / s), math.ceil(wh / s) +end + +-- zoom input is honored only while free-roaming the overworld +function Zoom.gateOK(top, overworld) + if top == nil or top ~= overworld then return false end + if top.transitioning then return false end + if top.runner and top.runner.isRunning and top.runner:isRunning() then + return false + end + return true +end + +return Zoom diff --git a/src/script/Commands.lua b/src/script/Commands.lua new file mode 100644 index 00000000..a75c58fd --- /dev/null +++ b/src/script/Commands.lua @@ -0,0 +1,612 @@ +-- Script command implementations. Each receives the script context. +-- Blocking commands yield the script coroutine and resume when the UI or +-- world event completes. +-- +-- Conditionals: check_flag stores its result in ctx.lastCheck; +-- jump_if_true/jump_if_false jump to an absolute row index. + +local Flags = require("src.script.Flags") +local Logger = require("src.core.Logger") +local TextBox = require("src.render.TextBox") + +local Commands = {} + +-- show_text [subs]: textId is looked up in generated +-- text (by label like "_PalletTownGirlText" or via the map's TEXT_* +-- pointers). subs replaces dynamic tokens, e.g. { RAM = "BULBASAUR" } +-- fills {RAM:wNameBuffer}. +-- +-- A preceding play_cry row (static-encounter battle text: PowerPlantZapdos- +-- BattleText and friends -- text_far "Gyaoo!@"/"Mew!@" + text_asm PlayCry + +-- WaitForSoundToFinish) leaves ctx.pendingCry set; that text has no / +-- of its own; the box only closes once WaitForSoundToFinish's poll +-- loop sees the cry channel go quiet, so it's a no-button-wait auto text +-- gated on the cry, not a button-wait one -- see TextBox's opts.auto. +function Commands.show_text(ctx, textId, subs) + local text = ctx.game.data.text[textId] + if not text and ctx.overworld then + text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) + end + if not text then + text = textId -- literal string fallback for hand-ported scripts + end + if subs then + for token, value in pairs(subs) do + -- A mod may transform a gift after the script row was authored. Keep + -- that replacement scoped to the immediately following received-mon + -- message; later script rows (such as the rival's gift) must use their + -- own explicit RAM value. + local replacement = value + if token == "RAM" and ctx.pendingPokemonName then + replacement = ctx.pendingPokemonName + ctx.pendingPokemonName = nil + end + text = text:gsub("{" .. token .. ":?[%w_]*}", replacement) + end + end + local runner = ctx.runner + local opts + if ctx.pendingCry then + local species = ctx.pendingCry + ctx.pendingCry = nil + opts = { auto = { sound = function() + return require("src.core.Sound").playCry(ctx.game.data, species) + end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own + end + ctx.game.stack:push(TextBox.new(ctx.game, text, function() + runner:resume() + end, opts)) + runner:yield() +end + +function Commands.jump(ctx, target) + return target +end + +-- ask [subs]: show text, then a YES/NO box; result lands in +-- ctx.lastCheck. subs are forwarded to show_text's {token} filling. +function Commands.ask(ctx, textId, subs) + Commands.show_text(ctx, textId, subs) + local ChoiceBox = require("src.ui.ChoiceBox") + local runner = ctx.runner + ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) + ctx.lastCheck = yes + runner:resume() + end)) + runner:yield() +end + +function Commands.face_player(ctx) + if ctx.npc and ctx.overworld then + ctx.npc:facePlayer(ctx.overworld.player) + end +end + +function Commands.set_flag(ctx, name) + Flags.set(ctx.save, name) +end + +function Commands.clear_flag(ctx, name) + Flags.clear(ctx.save, name) +end + +function Commands.check_flag(ctx, name) + ctx.lastCheck = Flags.get(ctx.save, name) +end + +function Commands.check_item(ctx, itemId) + ctx.lastCheck = (ctx.save.inventory[itemId] or 0) > 0 +end + +function Commands.jump_if_true(ctx, target) + if ctx.lastCheck then return target end +end + +function Commands.jump_if_false(ctx, target) + if not ctx.lastCheck then return target end +end + +-- give_item [count] [gotText]: adds to the bag, plays the gift +-- jingle and shows the "got item!" box. pokered's GiveItem (home/ +-- give.asm) copies the item name to wStringBuffer and every gift script +-- then prints a text ending " got\n!" with +-- sound_get_item_1/sound_get_key_item. gotText picks that per-script +-- text (label or literal; {RAM:wStringBuffer} becomes the item name); +-- pass false when the script shows its own received-text row. +function Commands.give_item(ctx, itemId, count, gotText) + -- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt + -- the script, so later set_flag rows don't burn the gift -- make + -- room and talk again, like the original (pokered's `jr nc, .bag_full` + -- skips the received text entirely when AddItemToInventory refuses) + if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then + Commands.show_text(ctx, "You can't carry\nany more items!") + return math.huge + end + local def = ctx.game.data.items[itemId] + -- GiveItem -> GetItemName + CopyToStringBuffer: the received texts + -- read the name back out of wStringBuffer + ctx.game.stringBuffer = def and def.name or itemId + -- the jingle rides the box -- Sound.play routes fanfares through + -- Music.duckForFanfare, like PlaySoundWaitForCurrent + require("src.core.Sound").play(ctx.game.data, + (def and def.keyItem) and "Get_Key_Item" or "Get_Item1") + if gotText ~= false then + Commands.show_text(ctx, gotText + or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!") + end +end + +function Commands.take_item(ctx, itemId, count) + local inv = ctx.save.inventory + inv[itemId] = math.max(0, (inv[itemId] or 0) - (count or 1)) + if inv[itemId] == 0 then inv[itemId] = nil end +end + +-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex +function Commands.start_battle(ctx, kind, a, b) + local BattleState = require("src.battle.BattleState") + local runner = ctx.runner + local battle + if kind == "wild" then + battle = BattleState.newWild(ctx.game, a, b) + else + battle = BattleState.newTrainer(ctx.game, a, b) + end + battle.onFinish = function(result) + ctx.lastBattleResult = result + ctx.lastCheck = result == "win" + if ctx.overworld then + ctx.overworld:afterBattle(result) + end + runner:resume() + end + ctx.game.stack:push(battle) + runner:yield() +end + +function Commands.warp(ctx, mapId, x, y, facing) + local runner = ctx.runner + ctx.overworld:startWarpTo(mapId, x, y, facing, function() + runner:resume() + end) + runner:yield() +end + +function Commands.wait(ctx, frames) + ctx.runner.waitingFrames = frames + ctx.runner:yield() +end + +local function walkEntity(ctx, entity, dir, tiles) + local runner = ctx.runner + ctx.overworld:scriptMove(entity, dir, tiles or 1, function() + runner:resume() + end) + runner:yield() +end + +function Commands.move_player(ctx, dir, tiles) + walkEntity(ctx, ctx.overworld.player, dir, tiles) +end + +function Commands.move_npc(ctx, objIndex, dir, tiles) + local npc = ctx.overworld:npcByIndex(objIndex) + if npc then walkEntity(ctx, npc, dir, tiles) end +end + +-- Walk an NPC to a target cell along the map's walkable grid (BFS, so +-- scripted walks route around furniture instead of clipping through it). +local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" }, + { -1, 0, "left" }, { 1, 0, "right" } } + +local function bfsPath(map, sx, sy, tx, ty) + local key = function(x, y) return y * 1000 + x end + local prev = { [key(sx, sy)] = false } + local queue = { { sx, sy } } + local qi = 1 + while queue[qi] do + local cx, cy = queue[qi][1], queue[qi][2] + qi = qi + 1 + if cx == tx and cy == ty then + local path = {} + local k = key(tx, ty) + while prev[k] do + table.insert(path, 1, prev[k][3]) + k = key(prev[k][1], prev[k][2]) + end + return path + end + for _, d in ipairs(DIRS4) do + local nx, ny = cx + d[1], cy + d[2] + local nk = key(nx, ny) + if prev[nk] == nil and map:inBounds(nx, ny) + and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then + prev[nk] = { cx, cy, d[3] } + table.insert(queue, { nx, ny }) + end + end + end + return nil +end + +function Commands.move_npc_to(ctx, objIndex, tx, ty) + local ow = ctx.overworld + local npc = ow:npcByIndex(objIndex) + if not npc then return end + local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty) + if not path then + Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty) + return + end + local runner = ctx.runner + local i = 0 + local function step() + i = i + 1 + if not path[i] then + runner:resume() + return + end + ow:scriptMove(npc, path[i], 1, step) + end + step() + runner:yield() +end + +function Commands.face(ctx, dir) + if ctx.npc then ctx.npc.facing = dir end +end + +-- face an arbitrary map object (by object_event index) +function Commands.face_object(ctx, objIndex, dir) + local npc = ctx.overworld and ctx.overworld:npcByIndex(objIndex) + if npc then npc.facing = dir end +end + +function Commands.face_npc(ctx) + -- make the player face the talking NPC + if ctx.npc and ctx.overworld then + local p = ctx.overworld.player + local dx, dy = ctx.npc.cellX - p.cellX, ctx.npc.cellY - p.cellY + if math.abs(dx) > math.abs(dy) then + p.facing = dx > 0 and "right" or "left" + else + p.facing = dy > 0 and "down" or "up" + end + end +end + +-- Set the player's facing to an explicit direction. Cutscene runners that +-- have no ctx.npc (e.g. the HALL_OF_FAME room script queued from onEnter) +-- can't use face_player/face_npc to turn the player toward a fixed object, +-- so this mirrors pokered writing wPlayerMovingDirection directly +-- (scripts/HallOfFame.asm HallOfFameOakCongratulationsScript sets +-- PLAYER_DIR_RIGHT before the Oak speech). +function Commands.face_player_dir(ctx, dir) + if ctx.overworld then ctx.overworld.player.facing = dir end +end + +-- Set a plain field on the save table (used for transient one-shot markers +-- consumed by a map's onEnter, e.g. save.pendingHallOfFame handed from the +-- Champions Room warp to the HALL_OF_FAME room cutscene). Not a flag: it +-- lives outside the event-flag namespace and is cleared on consumption. +function Commands.set_field(ctx, key, value) + ctx.save[key] = value +end + +local function toggleObject(ctx, mapId, objName, visible) + local save = ctx.save + save.objectToggles = save.objectToggles or {} + save.objectToggles[mapId] = save.objectToggles[mapId] or {} + save.objectToggles[mapId][objName] = visible + -- add/remove in place (a full map respawn would reset scripted NPC + -- positions mid-cutscene) + local ow = ctx.overworld + if not ow or ow.map.id ~= mapId then return end + if visible then + for _, n in ipairs(ow.npcs) do + if n.def.name == objName then return end + end + for _, obj in ipairs(ow.map.def.objects) do + if obj.name == objName then + local NPC = require("src.world.NPC") + local npc = NPC.new(ctx.game.data, mapId, obj) + table.insert(ow.npcs, npc) + table.insert(ow.entities, npc) + return + end + end + else + for i = #ow.npcs, 1, -1 do + if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end + end + for i = #ow.entities, 1, -1 do + local e = ow.entities[i] + if e.def and e.def.name == objName then table.remove(ow.entities, i) end + end + end +end + +function Commands.show_object(ctx, mapId, objName) + toggleObject(ctx, mapId, objName, true) +end + +function Commands.hide_object(ctx, mapId, objName) + toggleObject(ctx, mapId, objName, false) +end + +function Commands.play_sound(ctx, soundId) + require("src.core.Sound").play(ctx.game.data, soundId) +end + +-- play_cry : PlayCry (home/audio.asm). The text_asm bodies that +-- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> +-- WaitForSoundToFinish, all within the same text ID -- the cry only starts +-- once the box has finished typing, and the box then auto-closes (no A +-- press) the instant the cry finishes, rather than firing immediately +-- alongside the typewriter effect. Script rows run strictly in order, so +-- this stashes the species on ctx for the show_text row that always +-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory +-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is +-- done typing (see show_text's opts.auto). Headless-safe no-op there. +function Commands.play_cry(ctx, species) + ctx.pendingCry = species +end + +-- check_battle_result [r2 ...]: lastCheck = the last scripted +-- battle ended with any of the given results +-- ("win"|"lose"|"run"|"caught"), for branches like +-- Route12SnorlaxPostBattleScript's `ld a, [wBattleResult] / cp $2`. +function Commands.check_battle_result(ctx, ...) + ctx.lastCheck = false + for _, want in ipairs({ ... }) do + if ctx.lastBattleResult == want then ctx.lastCheck = true end + end +end + +function Commands.heal_party(ctx) + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(ctx.save.party) do + Pokemon.heal(mon) + end +end + +-- give_pokemon : _GivePokemon (engine/events/ +-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets +-- the asm's carry: true when the mon was given, false when both the +-- party and every box are full (that .boxFull path leaves the giver's +-- script able to offer again later, e.g. the Celadon Eevee ball). +function Commands.give_pokemon(ctx, species, level) + -- Native mods can transform a gift before the Pokémon object is created. + -- This is intentionally an event rather than a special-case starter hook: + -- mods can use the same seam for story gifts, fossils, or custom scripts. + local gift = { ctx = ctx, species = species, level = level } + if ctx.game.mods then + ctx.game.mods.events:emit("pokemon.before_give", gift) + species, level = gift.species, gift.level + end + local Pokemon = require("src.pokemon.Pokemon") + local Party = require("src.pokemon.Party") + local mon = Pokemon.new(ctx.game.data, species, level) + if gift.nickname then mon.nickname = gift.nickname end + ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species + ctx.pendingPokemonName = species + require("src.battle.BattleState").stampOT(ctx.save, mon) + if not Party.add(ctx.save.party, mon) then + if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then + ctx.lastCheck = false + return + end + end + local dex = ctx.save.pokedex + if dex then + dex.seen[species] = true + dex.owned[species] = true + end + ctx.lastCheck = true +end + +function Commands.give_money(ctx, amount) + ctx.save.money = math.max(0, ctx.save.money + amount) +end + +-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside +-- AnimateHallOfFame), run the induction showcase and the end credits, +-- autosave while THE END is up, then soft-reset to the title -- the whole +-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript +-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm, +-- scripts/HallOfFame.asm). +function Commands.record_hall_of_fame(ctx) + ctx.save.hallOfFame = ctx.save.hallOfFame or {} + local entry = {} + for _, mon in ipairs(ctx.save.party) do + table.insert(entry, { species = mon.species, level = mon.level, + nickname = mon.nickname }) + end + table.insert(ctx.save.hallOfFame, entry) + local runner = ctx.runner + local game = ctx.game + local HallOfFame = require("src.ui.HallOfFame") + local Credits = require("src.ui.Credits") + game.stack:push(HallOfFame.new(game, function() + -- the end credits roll after the induction (engine/movie/credits.asm) + game.stack:push(Credits.new(game, function() + runner:resume() + end, function() + -- THE END is on screen: HallOfFameResetEventsAndSaveScript sets + -- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the + -- save keeps the player standing in the HALL_OF_FAME room. (The + -- E4 room-script/event resets that precede the save in pokered are + -- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.) + ctx.save.lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 } + if game.writeSave then game:writeSave() end + end)) + end)) + runner:yield() + -- after the A/B press on THE END the script does `jp Init`: a soft + -- reset through the boot sequence -- copyright card + attract movie, + -- then the title screen (the same path Game:load boots through) + require("src.core.Music").stop() + while game.stack:top() do game.stack:pop() end + local okIntro, IntroMovie = pcall(require, "src.ui.IntroMovie") + if okIntro and IntroMovie then + game.stack:push(IntroMovie.new(game, function() + if game.makeTitleState then game.stack:push(game:makeTitleState()) end + end)) + elseif game.returnToTitle then + game:returnToTitle() + end +end + +-- The Viridian old man's catch tutorial (scripts/ViridianCity.asm +-- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws +-- one POKé BALL; nothing is kept. +function Commands.old_man_demo(ctx) + local BattleState = require("src.battle.BattleState") + local runner = ctx.runner + local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 } + local battle = BattleState.newWild(ctx.game, om.species, om.level) + battle:makeOldManDemo() + battle.onFinish = function() runner:resume() end + ctx.game.stack:push(battle) + runner:yield() +end + +-- Static overworld encounters (Snorlax, the legendary birds, Mewtwo): +-- a wild battle; the object disappears unless the player loses +-- (blackout). beatFlag, when given, is the EVENT_BEAT_* event set by +-- EndTrainerBattle (home/trainers.asm) on ANY non-blackout end -- win, +-- catch or flee alike -- which is why a fled legendary never returns. +function Commands.static_battle(ctx, species, level, beatFlag) + Commands.start_battle(ctx, "wild", species, level) + local result = ctx.lastBattleResult + if result ~= "lose" then + if beatFlag then Flags.set(ctx.save, beatFlag) end + if ctx.npc and ctx.npc.def.name and ctx.overworld then + toggleObject(ctx, ctx.overworld.map.id, ctx.npc.def.name, false) + end + end +end + +-- Open a mart by TEXT constant (used by scripts that mix dialogue with +-- shopping, like the Viridian clerk's parcel handout). +function Commands.open_mart(ctx, textConst) + local ow = ctx.overworld + local entry = ctx.game.data:textEntry(ow.map.def.label, textConst) + if not entry or not entry.mart then + Logger.warn("open_mart: no mart on %s/%s", ow.map.def.label, tostring(textConst)) + return + end + local ShopMenu = require("src.ui.ShopMenu") + local runner = ctx.runner + ctx.game.stack:push(ShopMenu.new(ctx.game, entry.mart, function() + runner:resume() + end)) + runner:yield() +end + +-- Rival battles pick the party from the player's starter choice +-- (parties are ordered by the rival's own starter; see parties.asm): +-- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2. +function Commands.rival_battle(ctx, oppClass, baseParty) + local offset = 0 + if Flags.get(ctx.save, "EVENT_CHOSE_SQUIRTLE") then + offset = 1 + elseif Flags.get(ctx.save, "EVENT_CHOSE_BULBASAUR") then + offset = 2 + end + Commands.start_battle(ctx, "trainer", oppClass, baseParty + offset) +end + +-- In-game trades (engine/events/in_game_trades.asm DoInGameTradeDialogue; +-- table from data/events/trades.asm via field.trades). The NPC wants +-- trades[index].give and hands over trades[index].get. doneFlag is this +-- trade's wCompletedInGameTradeFlags bit under a port name (FLAG_TEST +-- before the offer -> after-trade text; FLAG_SET on completion), so each +-- trade happens exactly once. Each trade's dialogset (1..3) picks the +-- _WannaTrade/_NoTrade/_WrongMon/_Thanks/_AfterTrade text +-- family (TradeTextPointers1/2/3). +function Commands.trade(ctx, tradeIndex, doneFlag) + local trade = ctx.game.data.field.trades[tradeIndex] + if not trade then + Logger.warn("trade: no trade %s", tostring(tradeIndex)) + return + end + local data = ctx.game.data + local wantName = data.pokemon[trade.give] and data.pokemon[trade.give].name or trade.give + local getName = data.pokemon[trade.get] and data.pokemon[trade.get].name or trade.get + local dialogset = trade.dialogset or 1 -- older generated data: casual + local subs = { + ["RAM:wInGameTradeGiveMonName"] = wantName, + ["RAM:wInGameTradeReceiveMonName"] = getName, + } + local function say(label) + Commands.show_text(ctx, label, subs) + end + if doneFlag and Flags.get(ctx.save, doneFlag) then + say("_AfterTrade" .. dialogset .. "Text") + return + end + Commands.ask(ctx, "_WannaTrade" .. dialogset .. "Text", subs) + if not ctx.lastCheck then + say("_NoTrade" .. dialogset .. "Text") + return + end + -- InGameTrade_DoTrade: DisplayPartyMenu -- the player picks which mon + -- to hand over; backing out reuses the no-trade text, a mon of the + -- wrong species gets the wrong-mon text + local party = ctx.save.party + local runner = ctx.runner + local picked + local PartyMenu = require("src.ui.PartyMenu") + ctx.game.stack:push(PartyMenu.new(ctx.game, { + pickOnly = true, + onCancel = function() runner:resume() end, + onSwitch = function(mon) + picked = mon + runner:resume() + end, + })) + runner:yield() + if not picked then + say("_NoTrade" .. dialogset .. "Text") + return + end + if picked.species ~= trade.give then + say("_WrongMon" .. dialogset .. "Text") + return + end + local slot + for i, mon in ipairs(party) do + if mon == picked then slot = i break end + end + if not slot then return end -- unreachable: picked came from the party + if doneFlag then Flags.set(ctx.save, doneFlag) end + say("_ConnectCableText") + local Pokemon = require("src.pokemon.Pokemon") + local sent = party[slot] + -- the received mon keeps the sent mon's level (wCurEnemyLevel) and, + -- like RemovePokemon + AddPartyMon, joins at the end of the party + local newMon = Pokemon.new(data, trade.get, sent.level) + newMon.nickname = trade.nickname + newMon.traded = true -- boosted exp + Name Rater refusal (different OT) + table.remove(party, slot) + table.insert(party, newMon) + local dex = ctx.save.pokedex + if dex then + dex.seen[trade.get] = true + dex.owned[trade.get] = true + end + -- the trade machine animation (engine/movie/trade.asm) + local TradeAnim = require("src.ui.TradeAnim") + ctx.game.stack:push(TradeAnim.new(ctx.game, { + sent = sent, received = newMon, + onDone = function() runner:resume() end, + })) + runner:yield() + -- TradedForText (sound_get_key_item) then the dialogset's thanks + require("src.core.Sound").play(data, "Get_Key_Item") + say("_TradedForText") + say("_Thanks" .. dialogset .. "Text") +end + +return Commands diff --git a/src/script/Flags.lua b/src/script/Flags.lua new file mode 100644 index 00000000..db88b3b2 --- /dev/null +++ b/src/script/Flags.lua @@ -0,0 +1,18 @@ +-- Event flags stored in the save table, keyed by pokered event constant +-- names (e.g. "EVENT_FOLLOWED_OAK_INTO_LAB"). + +local Flags = {} + +function Flags.set(save, name) + save.flags[name] = true +end + +function Flags.clear(save, name) + save.flags[name] = nil +end + +function Flags.get(save, name) + return save.flags[name] == true +end + +return Flags diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua new file mode 100644 index 00000000..74bc323a --- /dev/null +++ b/src/script/ScriptRunner.lua @@ -0,0 +1,99 @@ +-- Executes map scripts: lists of { "command", args... } rows (see +-- src/script/Commands.lua and data/scripts/). Runs as a coroutine so +-- commands like show_text and wait can block on UI. +-- +-- Map-specific behavior lives in data/scripts/.lua modules, never in +-- engine code. Each hand-ported script references its asm source. + +local Commands = require("src.script.Commands") +local Logger = require("src.core.Logger") + +local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility + +local ScriptRunner = {} +ScriptRunner.__index = ScriptRunner + +function ScriptRunner.new(game, overworld) + local self = setmetatable({}, ScriptRunner) + self.game = game + self.overworld = overworld + self.co = nil + return self +end + +function ScriptRunner:isRunning() + return self.co ~= nil and coroutine.status(self.co) ~= "dead" +end + +-- ctx passed to commands: engine services plus per-run info (npc, map) +function ScriptRunner:makeContext(extra) + local ctx = { + game = self.game, + overworld = self.overworld, + save = self.game.save, + runner = self, + } + for k, v in pairs(extra or {}) do ctx[k] = v end + return ctx +end + +function ScriptRunner:run(script, extra) + assert(not self:isRunning(), "script already running") + local ctx = self:makeContext(extra) + self.co = coroutine.create(function() + self:exec(script, ctx) + if ctx.onDone then ctx.onDone() end + end) + self:resume() +end + +-- Execute a command list. Supports labels via jump commands: a script is +-- an array of rows; control commands return a new program counter. +function ScriptRunner:exec(script, ctx) + local pc = 1 + while pc <= #script do + local row = script[pc] + local name = row[1] + local fn = Commands[name] + if not fn then + Logger.warn("script: unknown command '%s' (skipped)", tostring(name)) + pc = pc + 1 + else + local jump = fn(ctx, select(2, unpack(row))) + if type(jump) == "number" then + pc = jump + else + pc = pc + 1 + end + end + end +end + +-- Called by blocking commands from inside the coroutine. +function ScriptRunner:yield() + coroutine.yield() +end + +function ScriptRunner:resume(...) + if not self.co then return end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Logger.error("script error: %s", tostring(err)) + self.co = nil + elseif coroutine.status(self.co) == "dead" then + self.co = nil + end +end + +function ScriptRunner:update() + -- commands that wait on frames re-resume every step while running + if self:isRunning() and self.waitingFrames then + self.waitingFrames = self.waitingFrames - 1 + if self.waitingFrames <= 0 then + self.waitingFrames = nil + self:resume() + end + end +end + +return ScriptRunner diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua new file mode 100644 index 00000000..4d428b99 --- /dev/null +++ b/src/ui/BagMenu.lua @@ -0,0 +1,396 @@ +-- The bag: lists inventory, uses items via ItemEffects. +-- opts.battle = BattleState when opened mid-battle (balls throwable, +-- using an item consumes the turn). + +local ItemEffects = require("src.inventory.ItemEffects") +local ListMenu = require("src.ui.ListMenu") +local TextBox = require("src.render.TextBox") + +local BagMenu = {} + +local Bag = require("src.inventory.Bag") + +-- acquisition order like wBagItems (Bag.order), not alphabetical +local function buildItems(game) + local items = {} + for _, id in ipairs(Bag.order(game.save)) do + local def = game.data.items[id] + table.insert(items, { + value = id, + label = def and def.name or id, + right = "x" .. game.save.inventory[id], + }) + end + return items +end + +local function consume(game, id) + Bag.remove(game.save, id, 1) +end + +local function save_name(game) + return game.save.player.name +end + +local function showMessages(game, msgs, onDone) + if not msgs or #msgs == 0 then + if onDone then onDone() end + return + end + game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone)) +end + +-- run the use-flow for an item on a chosen target +local function useOn(game, battle, id, target, list, moveIndex) + local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, + battle, moveIndex, game.overworld) + + -- field POKé FLUTE: play the tune, then the no-effect text + if result == "flute_field" then + require("src.core.Sound").play(game.data, "Pokeflute") + showMessages(game, payload) + return + end + + -- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text, + -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) + if result == "flute_wake" then + list:close() + require("src.core.Sound").play(game.data, "Pokeflute") + showMessages(game, payload, function() + local ow = game.overworld + local mod = ow and require("data.scripts.init").get(extra.mapId) + if ow and mod and mod.snorlaxWake then + ow.runner:run(mod.snorlaxWake.script, { npc = extra.npc }) + end + end) + return + end + + if result == "consumed_escape" then -- Poké Doll + consume(game, id) + list:close() + showMessages(game, payload, function() + battle.result = "run" + battle.afterQueue = "finish" + battle.phase = "messages" + end) + return + end + + if result == "bicycle" then + list:close() + local ow = game.overworld + local Music = require("src.core.Music") + -- IsBikeRidingAllowed (home/overworld.asm): the tilesets of + -- bike_riding_tilesets.asm, plus Route 23 / Indigo Plateau by + -- map id. Reads the extracted allowlist when present. + local function bikeAllowed() + if not ow then return false end + local br = game.data.field.bikeRiding + or { tilesets = { "OVERWORLD", "FOREST", "UNDERGROUND", + "SHIP_PORT", "CAVERN" }, + maps = { "ROUTE_23", "INDIGO_PLATEAU" } } + for _, m in ipairs(br.maps or {}) do + if ow.map.id == m then return true end + end + for _, t in ipairs(br.tilesets or {}) do + if ow.map.def.tileset == t then return true end + end + return false + end + if game.save.onBike then + game.save.onBike = false + Music.playMap(game.data, ow and ow.map.id, false) + showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." }) + elseif bikeAllowed() then + game.save.onBike = true + Music.playMap(game.data, ow.map.id, true) + showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" }) + else + showMessages(game, { "No cycling\nallowed here." }) + end + return + end + + if result == "fish" then + list:close() + local ow = game.overworld + local p = ow and ow.player + if ow and p then + local fx, fy = p:facingCell() + if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then + ow:goFishing(id) + return + end + end + showMessages(game, { "No good! It's not\neven near water." }) + return + end + + if result == "ball" then + if not battle then + showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" }) + return + end + consume(game, id) + list:close() + battle:throwBall(id) + return + end + + if result == "learn" or result == "learnkept" then + local moveId = payload + local mdef = game.data.moves[moveId] + local function teach() + if #target.moves < 4 then + table.insert(target.moves, { id = moveId, pp = mdef.pp }) + showMessages(game, { ("%s learned\n%s!"):format(target.nickname or + game.data.pokemon[target.species].name, mdef.name) }) + if result == "learn" then consume(game, id) end + else + local MoveLearnMenu = require("src.ui.MoveLearnMenu") + game.stack:push(MoveLearnMenu.new(game, target, moveId, function(learned) + if learned and result == "learn" then consume(game, id) end + end)) + end + end + list:close() + teach() + return + end + + -- the TOWN MAP screen (engine/menus/town_map.asm) + if result == "townmap" then + local ok, TownMap = pcall(require, "src.ui.TownMap") + if ok then + game.stack:push(TownMap.new(game)) + else + showMessages(game, { "The TOWN MAP is\nunreadable here." }) + end + return + end + + -- ITEMFINDER (engine/items/itemfinder.asm): responds if the current + -- map still has an unfound hidden item + if result == "itemfinder" then + local ow = game.overworld + local t = game.data.text + if ow and ow:hasHiddenItemLeft() then + showMessages(game, { t._ItemfinderFoundItemText + or "Yes! ITEMFINDER\nindicates there's\nan item nearby." }) + else + showMessages(game, { t._ItemfinderFoundNothingText + or "Nope! ITEMFINDER\nisn't responding." }) + end + return + end + + -- POKé FLUTE in battle: not consumed, but uses the turn + if result == "flute" then + list:close() + require("src.core.Sound").play(game.data, "Pokeflute") + showMessages(game, payload, function() battle:itemUsed({}) end) + return + end + + if result == "escape_rope" then + -- ItemUseEscapeRope: only inside the dungeon tilesets + -- (escape_rope_tilesets.asm), never in Agatha's room, and it sets + -- BIT_ESCAPE_WARP so special_warps.asm warps to wLastBlackoutMap + -- -- the last Pokémon Center town, same as Dig/Teleport (NOT the + -- spot you entered the dungeon from) + local ESCAPE_ROPE_TILESETS = { FOREST = true, CEMETERY = true, + CAVERN = true, FACILITY = true, + INTERIOR = true } + local ow = game.overworld + if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset] + and ow.map.id ~= "AGATHAS_ROOM" then + list:close() + consume(game, id) + require("src.core.Sound").play(game.data, "Teleport_Exit1") + ow.player.surfing = false + ow:warpToHealPoint() + else + showMessages(game, { "OAK: " .. game.save.player.name + .. "!\nThis isn't the\ntime to use that!" }) + end + return + end + + if result == "consumed" then + consume(game, id) + if extra and extra.evolveTo then + list:close() + local Evolution = require("src.pokemon.Evolution") + Evolution.evolve(game, target, extra.evolveTo) + return + end + -- RARE CANDY: after the level text, the stat window, any level-up + -- moves and a level evolution follow (item_effects.asm .useRareCandy + -- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon) + if extra and extra.leveledTo and target then + list:close() + showMessages(game, payload, function() + local StatBox = require("src.battle.BattleState").StatBox + game.stack:push(StatBox.new(game, target, function() + local Experience = require("src.battle.Experience") + local def = game.data.pokemon[target.species] + local moves = Experience.movesLearnedAt(def, extra.leveledTo) + local i = 0 + local function nextStep() + i = i + 1 + local moveId = moves[i] + if not moveId then + local Evolution = require("src.pokemon.Evolution") + local evoTo = Evolution.pendingLevelEvo(game.data, target) + if evoTo then Evolution.evolve(game, target, evoTo) end + return + end + for _, mv in ipairs(target.moves) do + if mv.id == moveId then return nextStep() end + end + local mdef = game.data.moves[moveId] + if #target.moves < 4 then + table.insert(target.moves, { id = moveId, pp = mdef.pp }) + local name = target.nickname or def.name + showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) }, + nextStep) + else + local MoveLearnMenu = require("src.ui.MoveLearnMenu") + game.stack:push(MoveLearnMenu.new(game, target, moveId, nextStep)) + end + end + nextStep() + end)) + end) + return + end + -- refresh counts in the list + for i, it in ipairs(list.items) do + if it.value == id then + local left = game.save.inventory[id] + if left then it.right = "x" .. left else table.remove(list.items, i) end + break + end + end + list.index = math.min(list.index, math.max(1, #list.items)) + if battle then + list:close() + showMessages(game, payload, function() battle:itemUsed({}) end) + else + showMessages(game, payload) + end + return + end + + showMessages(game, payload) -- failed +end + +local function useItem(game, battle, id, list) + local def = game.data.items[id] + if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then + -- pick a target from the party + local PartyMenu = require("src.ui.PartyMenu") + -- the ETHERs and PP UP open the move menu after picking a mon + -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move + local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" + game.stack:push(PartyMenu.new(game, { + pickOnly = true, + onSwitch = function(mon) + if not wantsMove then + useOn(game, battle, id, mon, list) + return + end + local rows = {} + for mi, mv in ipairs(mon.moves) do + local mdef = game.data.moves[mv.id] + table.insert(rows, { + value = mi, + label = mdef and mdef.name or mv.id, + right = ("%d"):format(mv.pp), + }) + end + game.stack:push(ListMenu.new(game, "Which move?", rows, { + onChoose = function(row, l) + l:close() + useOn(game, battle, id, mon, list, row.value) + end, + })) + end, + })) + else + useOn(game, battle, id, nil, list) + end +end + +function BagMenu.new(game, opts) + opts = opts or {} + local battle = opts.battle + local list + list = ListMenu.new(game, "ITEMS", buildItems(game), { + footer = ("¥%d"):format(game.save.money), + -- SELECT reorders items like the original bag (swap_items.asm) + onSelectKey = function(item, l) + if not item then return end + if not l.swapIndex then + l.swapIndex = l.index + return + end + local order = Bag.order(game.save) + order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex] + l.swapIndex = nil + require("src.core.Sound").play(game.data, "Swap") + l.items = buildItems(game) + end, + onChoose = function(item) + local id = item.value + local def = game.data.items[id] + if list.swapIndex then -- A also completes a pending swap + local order = Bag.order(game.save) + order[list.swapIndex], order[list.index] = order[list.index], order[list.swapIndex] + list.swapIndex = nil + require("src.core.Sound").play(game.data, "Swap") + list.items = buildItems(game) + return + end + if battle then -- no tossing mid-battle + useItem(game, battle, id, list) + return + end + -- USE / TOSS submenu (the original's item options) + local Menu = require("src.ui.Menu") + game.stack:push(Menu.new(game, { + { label = "USE", onSelect = function() + useItem(game, battle, id, list) + end }, + { label = "TOSS", onSelect = function() + -- KeyItemFlags + HMs decide tossability (not price: + -- MOON STONE is price 0 but tossable) + if not def or def.keyItem or id:find("^HM_") then + showMessages(game, { "That's too impor-\ntant to toss!" }) + return + end + local QuantityBox = require("src.ui.QuantityBox") + game.stack:push(QuantityBox.new(game, { + max = game.save.inventory[id] or 1, + onDone = function(qty) + if not qty then return end + local ChoiceBox = require("src.ui.ChoiceBox") + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + Bag.remove(game.save, id, qty) + list.items = buildItems(game) + list.index = math.min(list.index, math.max(1, #list.items)) + showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) }) + end)) + end, + })) + end }, + }, { tx = 12, ty = 10, tw = 8, th = 6 })) + end, + }) + return list +end + +return BagMenu diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua new file mode 100644 index 00000000..074bc9a1 --- /dev/null +++ b/src/ui/BoxMenu.lua @@ -0,0 +1,160 @@ +-- PC storage: 12 boxes of 20 (engine/pokemon/bills_pc.asm semantics via +-- src/pokemon/Boxes.lua): withdraw from / deposit to the current box, +-- plus CHANGE BOX. + +local Boxes = require("src.pokemon.Boxes") +local ListMenu = require("src.ui.ListMenu") +local Menu = require("src.ui.Menu") +local Party = require("src.pokemon.Party") + +local BoxMenu = {} + +local function monLabel(game, mon) + local def = game.data.pokemon[mon.species] + return ("%s :L%d"):format(mon.nickname or def.name, mon.level) +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) + game.stack:push(Menu.new(game, { + { label = action, onSelect = onAction }, + { + label = "STATS", + keepOpen = true, + onSelect = function() + local SummaryMenu = require("src.ui.SummaryMenu") + game.stack:push(SummaryMenu.new(game, mon)) + end, + }, + { label = "CANCEL" }, + }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) +end + +local function withdraw(game) + local box = Boxes.active(game.save) + local items = {} + for i, mon in ipairs(box) do + table.insert(items, { label = monLabel(game, mon), value = i }) + end + game.stack:push(ListMenu.new(game, + ("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, { + onChoose = function(item, list) + local mon = box[item.value] + if not mon then return end + monSubmenu(game, "WITHDRAW", mon, function() + if #game.save.party >= Party.MAX then + list.footer = "The party is full!" + return + end + table.remove(box, item.value) + table.insert(game.save.party, mon) + list:close() + end) + end, + })) +end + +local function deposit(game) + local items = {} + for i, mon in ipairs(game.save.party) do + table.insert(items, { label = monLabel(game, mon), value = i }) + end + game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { + onChoose = function(item, list) + local mon = game.save.party[item.value] + if not mon then return end + monSubmenu(game, "DEPOSIT", mon, function() + if #game.save.party <= 1 then + list.footer = "You need at least\none POKéMON!" + return + end + local box = Boxes.active(game.save) + if #box >= Boxes.CAPACITY then + list.footer = ("BOX %d is full!"):format(game.save.currentBox) + return + end + table.remove(game.save.party, item.value) + table.insert(box, mon) + list:close() + end) + end, + })) +end + +-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!" +local function release(game) + local box = Boxes.active(game.save) + local items = {} + for i, mon in ipairs(box) do + table.insert(items, { label = monLabel(game, mon), value = i }) + end + game.stack:push(ListMenu.new(game, + ("BOX %d (RELEASE)"):format(game.save.currentBox), items, { + onChoose = function(item, list) + local mon = box[item.value] + if not mon then return end + local def = game.data.pokemon[mon.species] + local name = mon.nickname or def.name + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + "Once released,\n" .. name .. " is\ngone forever. OK?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + table.remove(box, item.value) + require("src.core.Sound").playCry(game.data, mon.species) + game.stack:push(TextBox.new(game, + ("%s was\nreleased outside.\fBye %s!"):format(name, name))) + list:removeCurrent() + end, { defaultNo = true, noSound = true })) + end)) + end, + })) +end + +local function changeBox(game) + local boxes = Boxes.ensure(game.save) + local items = {} + for i = 1, Boxes.COUNT do + local mark = i == game.save.currentBox and "*" or " " + table.insert(items, { + label = ("%sBOX %2d"):format(mark, i), + right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY), + value = i, + }) + end + game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { + onChoose = function(item, list) + -- the original asks BEFORE switching ("When you change a #MON + -- BOX, data will be saved. OK?"); declining aborts the change + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + game.save.currentBox = item.value + if game.writeSave then game:writeSave() end + list:close() + end, { noSound = true })) + end)) + end, + })) +end + +function BoxMenu.new(game) + Boxes.ensure(game.save) + return Menu.new(game, { + { label = "WITHDRAW", onSelect = function() withdraw(game) end }, + { label = "DEPOSIT", onSelect = function() deposit(game) end }, + { label = "RELEASE", onSelect = function() release(game) end }, + { label = "CHANGE BOX", onSelect = function() changeBox(game) end }, + { label = "SEE YA!" }, + -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, + -- engine/menus/pokemon_pc.asm) + }, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true }) +end + +return BoxMenu diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua new file mode 100644 index 00000000..63e3996e --- /dev/null +++ b/src/ui/ChoiceBox.lua @@ -0,0 +1,50 @@ +-- YES/NO choice box (top-left of the text box area, like the original). + +local Font = require("src.render.Font") + +local ChoiceBox = {} +ChoiceBox.__index = ChoiceBox + +local CURSOR = 0xED + +function ChoiceBox.new(game, onChoose, opts) + local self = setmetatable({}, ChoiceBox) + self.game = game + self.onChoose = onChoose + -- some of the original's prompts start on NO (e.g. release) + self.index = (opts and opts.defaultNo) and 2 or 1 + -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent + self.noSound = (opts and opts.noSound) or false + return self +end + +function ChoiceBox:update(dt) + local input = self.game.input + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("a") then + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + self.onChoose(self.index == 1) + elseif input:wasPressed("b") then + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + self.onChoose(false) + end +end + +function ChoiceBox:draw() + Font.drawBox(0, 7, 6, 5) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("YES", 16, 8 * 8) + Font.draw("NO", 16, 10 * 8) + Font.drawCode(CURSOR, 8, (self.index == 1 and 8 or 10) * 8) + love.graphics.setColor(1, 1, 1, 1) +end + +return ChoiceBox diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua new file mode 100644 index 00000000..18b7266f --- /dev/null +++ b/src/ui/Credits.lua @@ -0,0 +1,327 @@ +-- Screen-by-screen end credits (engine/movie/credits.asm HallOfFamePC + +-- Credits). After the Hall of Fame induction fades out, the screen sits +-- blank for 100 frames, then the black letterbox bars appear +-- (FillFourRowsWithBlack: rows 0-3 and 14-17), Music_Credits starts and +-- the first screen follows 128 frames later. Each CreditsOrder screen +-- places its lines at hlcoord 9,6 plus the per-line signed column offset +-- (rows 6, 8, 10, ...) and runs its terminator: +-- CRED_TEXT_FADE_MON fade in (4 BGP steps x 5 frames), hold 90, mon wipe +-- CRED_TEXT_MON text appears at once, hold 110, mon wipe +-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text +-- CRED_TEXT text appears at once, hold 140 +-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per +-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next +-- CreditsMons entry crosses right-to-left as a black silhouette +-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which +-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT +-- composes the Nintendo / Creatures inc. / GAME FREAK inc. block on its +-- screen (LoadCopyrightTiles: rows 7/9/11 from column 2). CRED_THE_END +-- waits 16 frames on the blank band, shows the interleaved THE END +-- letters at tile (4,8), and runs one more FadeInCredits (a no-op: the +-- letters are color 3, so they are black from the start). +-- +-- Then the caller's onTheEnd fires -- the point where +-- HallOfFameResetEventsAndSaveScript (scripts/HallOfFame.asm) sets +-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData -- the screen +-- holds 600 more frames (the script's 5 x 120 DelayFrames) and finally +-- waits for A/B (WaitForTextScrollButtonPress: no visible arrow here and +-- no press SFX) before popping and calling onDone (the script's +-- `jp Init`). If field.credits hasn't been extracted the roll degrades +-- to just THE END. + +local Font = require("src.render.Font") +local Music = require("src.core.Music") + +local Credits = {} +Credits.__index = Credits +Credits.isOpaque = true + +-- FadeInCredits: HoFGBPalettes steps the text color index through GB +-- shades 0 (white) -> 1 -> 2 -> 3 (black), 5 frames per step. The font +-- glyphs are black-on-transparent, so drawing them at these alphas over +-- the white band reproduces the 255 -> 170 -> 85 -> 0 gray ramp. +local FADE_STEPS = { 0, 1 / 3, 2 / 3, 1 } +local FADE_STEP_FRAMES = 5 +local FADE_FRAMES = FADE_STEP_FRAMES * #FADE_STEPS -- 20 + +-- DelayFrames after each screen's text is up (Credits .next1/.next2) +local HOLD_FADE_MON = 90 +local HOLD_MON = 110 +local HOLD_FADE = 120 +local HOLD_TEXT = 140 + +local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame + +-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile +-- sequences into the extracted title/copyright.png strip (tiles $60-$72: +-- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is +-- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles +-- $73-$7B), with the intro's composed gamefreak_text.png as a fallback +-- for pre-regeneration data. +local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98 +local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo +local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc. + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +-- DisplayCreditsMon shows the mon as a black silhouette: BGP %11111100 +-- maps colors 1-3 to black and keeps color 0 white. The extracted +-- front-sprite PNGs keep GB color 0 as white/transparent pixels, so +-- paint every opaque non-white pixel black. Without love.image +-- (headless stub) fall back to a black tint (second return value), which +-- also blackens interior color-0 pixels. +local function silhouette(path) + if not path then return nil end + if love.image and love.image.newImageData then + local ok, imgData = pcall(love.image.newImageData, path) + if ok and imgData then + imgData:mapPixel(function(_, _, r, g, b, a) + if a > 0 and r + g + b < 2.9 then return 0, 0, 0, 1 end + return r, g, b, a + end) + local ok2, img = pcall(love.graphics.newImage, imgData) + if ok2 and img then return img, false end + end + end + local ok, img = pcall(love.graphics.newImage, path) + if ok and img then return img, true end + return nil +end + +function Credits.new(game, onDone, onTheEnd) + local self = setmetatable({}, Credits) + self.game = game + self.onDone = onDone + self.onTheEnd = onTheEnd + local credits = game.data.field and game.data.field.credits or {} + self.screens = credits.screens or {} + self.theEnd = credits.theEnd + self.index = 0 + self.screen = nil + self.phase = "white" + self.timer = 100 -- HallOfFamePC: ClearScreen + 100 DelayFrames + self.shade = 0 + + -- assets (all optional; missing ones fall back to Font glyphs) + self.endImg = tryImage(self.theEnd and self.theEnd.path) + self.endQuads = {} + if self.endImg then + local iw, ih = self.endImg:getDimensions() + for l = 0, 4 do -- 8x16 letter columns T,H,E,N,D + self.endQuads[l] = love.graphics.newQuad(l * 8, 0, 8, 16, iw, ih) + end + end + local title = game.data.field and game.data.field.title + self.copyImg = tryImage(title and title.copyright and title.copyright.path) + self.copyQuads = {} + if self.copyImg then + local iw, ih = self.copyImg:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + local intro = game.data.field and game.data.field.intro + self.gfImg = tryImage(title and title.gamefreakInc + and title.gamefreakInc.path) + or tryImage(intro and intro.gamefreakText + and intro.gamefreakText.path) + return self +end + +function Credits:enter() + -- AnimateHallOfFame ended on HoFFadeOutScreenAndMusic: silence over the + -- blank lead-in; MUSIC_CREDITS starts when the bars appear + pcall(Music.stop) +end + +function Credits:monSprite(species) + local def = self.game.data.pokemon and self.game.data.pokemon[species] + return silhouette(def and def.spriteFront) +end + +-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen); +-- past the last one, CRED_THE_END takes over +function Credits:nextScreen() + self.index = self.index + 1 + local screen = self.screens[self.index] + self.screen = screen + if not screen then + self.phase = "end_blank" -- .showTheEnd: ld c, 16 on the blank band + self.timer = 16 + return + end + if screen.fade then + self.phase = "fade" + self.timer = FADE_FRAMES + self.shade = 0 + else + -- no fade: BGP was left black by the previous screen's fade + self.phase = "hold" + self.shade = 1 + self.timer = screen.mon and HOLD_MON or HOLD_TEXT + end +end + +function Credits:update(dt) + if self.phase == "end_wait" then + -- WaitForTextScrollButtonPress: A or B ends the credits; the HoF + -- script then soft-resets (`jp Init`). No SFX on this press. + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone() end + end + return + end + self.timer = self.timer - 1 + if self.timer > 0 then + if self.phase == "fade" then + local step = math.floor((FADE_FRAMES - self.timer) / FADE_STEP_FRAMES) + self.shade = FADE_STEPS[math.min(#FADE_STEPS, step + 1)] + end + return + end + if self.phase == "white" then + -- bars on, stop-music SFX + PlayMusic MUSIC_CREDITS, then 128 frames + self.phase = "intro" + self.timer = 128 + local data = self.game.data + if data.audio and data.audio.songs and data.audio.songs.Music_Credits then + pcall(Music.play, data, "Music_Credits") + end + elseif self.phase == "intro" then + self:nextScreen() + elseif self.phase == "fade" then + self.shade = 1 + self.phase = "hold" + self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE + elseif self.phase == "hold" then + if self.screen.mon then + self.phase = "wipe" + self.timer = WIPE_FRAMES + self.monImg, self.monTint = self:monSprite(self.screen.mon) + else + self:nextScreen() + end + elseif self.phase == "wipe" then + self.monImg = nil + self:nextScreen() + elseif self.phase == "end_blank" then + -- THE END letters are color 3: visible from the first fade palette + self.phase = "end_fade" + self.timer = FADE_FRAMES + elseif self.phase == "end_fade" then + -- Credits returns to HallOfFameResetEventsAndSaveScript here: the + -- save happens now, then 5 x 120 DelayFrames before the button wait + if self.onTheEnd then self.onTheEnd() end + self.phase = "end_hold" + self.timer = 600 + elseif self.phase == "end_hold" then + self.phase = "end_wait" + end +end + +-- one credits screen: lines at rows 6/8/10... with the extractor's +-- absolute column (9 + signed offset), plus the copyright block +function Credits:drawPage(screen, xoff, shade) + if not screen then return end + love.graphics.setColor(0, 0, 0, shade) + for i, line in ipairs(screen.lines or {}) do + Font.draw(line.text, xoff + (line.column or 0) * 8, 48 + (i - 1) * 16) + end + love.graphics.setColor(1, 1, 1, 1) + if screen.copyright then self:drawCopyright(xoff) end +end + +function Credits:drawCopyright(xoff) + local img = self.copyImg + if img then + -- the copyright tiles are loaded fresh (not color-shifted like the + -- font), so they are color 3: always solid, no fade + local function row(seq, x, y) + for _, t in ipairs(seq) do + love.graphics.draw(img, self.copyQuads[t], x, y) + x = x + 8 + end + return x + end + row(COPY_PREFIX, xoff + 16, 56) + row(COPY_NINTENDO, xoff + 80, 56) + row(COPY_PREFIX, xoff + 16, 72) + row(COPY_CREATURES, xoff + 80, 72) + row(COPY_PREFIX, xoff + 16, 88) + if self.gfImg then + love.graphics.draw(self.gfImg, xoff + 80, 88) + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw("GAME FREAK", xoff + 80, 88) + love.graphics.setColor(1, 1, 1, 1) + end + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw("Nintendo", xoff + 80, 56) + Font.draw("Creatures inc.", xoff + 80, 72) + Font.draw("GAME FREAK inc.", xoff + 16, 88) + love.graphics.setColor(1, 1, 1, 1) + end +end + +-- the mon silhouette crossing during the wipe; x is the left edge of its +-- 7x7 box (bottom-centered inside it, like the GB pic buffer padding) +function Credits:drawMon(x) + local img = self.monImg + if not img then return end + local w, h = img:getDimensions() + if self.monTint then love.graphics.setColor(0, 0, 0, 1) end + love.graphics.draw(img, x + math.floor((56 - w) / 2), 48 + (56 - h)) + love.graphics.setColor(1, 1, 1, 1) +end + +-- TheEndTextString: 12 tile columns from (4,8), each an 8x16 letter +-- column of the interleaved the_end gfx (pattern indexes T,H,E,N,D) +function Credits:drawTheEnd() + local te = self.theEnd + if self.endImg and te and te.pattern then + love.graphics.setColor(1, 1, 1, 1) + for i, letter in ipairs(te.pattern) do + if letter >= 0 then + love.graphics.draw(self.endImg, self.endQuads[letter], + 32 + (i - 1) * 8, 64) + end + end + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw((te and te.display) or "T H E E N D", 32, 64) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function Credits:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + if self.phase == "white" then return end + -- FillFourRowsWithBlack: rows 0-3 and 14-17 stay solid black + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 160, 32) + love.graphics.rectangle("fill", 0, 112, 160, 32) + love.graphics.setColor(1, 1, 1, 1) + if self.phase == "fade" or self.phase == "hold" then + self:drawPage(self.screen, 0, self.shade) + elseif self.phase == "wipe" then + -- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while + -- the silhouette enters from the right edge one screen behind it + local s = (WIPE_FRAMES - self.timer) * 8 + self:drawPage(self.screen, -s, 1) + self:drawMon(160 - s) + elseif self.phase == "end_fade" or self.phase == "end_hold" + or self.phase == "end_wait" then + self:drawTheEnd() + end + love.graphics.setColor(1, 1, 1, 1) +end + +return Credits diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua new file mode 100644 index 00000000..41140d0a --- /dev/null +++ b/src/ui/DexEntryMenu.lua @@ -0,0 +1,72 @@ +-- Pokédex entry page: front sprite, kind, height/weight and the real +-- dex description (data/pokemon/dex_entries.asm + dex_text.asm). + +local Font = require("src.render.Font") + +local DexEntryMenu = {} +DexEntryMenu.__index = DexEntryMenu +DexEntryMenu.isOpaque = true + +-- SGB: PalPacket_Pokedex (BROWNMON) + the mon pic zone in its palette +function DexEntryMenu:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local base = P.pal(game.data, "BROWNMON") + if not base then return nil end + return { P.whole(base), + P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) } +end + +function DexEntryMenu.new(game, species) + local self = setmetatable({ game = game }, DexEntryMenu) + self.def = game.data.pokemon[species] + local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) + self.sprite = ok and img or nil + require("src.core.Sound").playCry(game.data, species) + return self +end + +function DexEntryMenu:update(dt) + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + end +end + +function DexEntryMenu:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local def = self.def + if self.sprite then + love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight())) + end + love.graphics.setColor(0, 0, 0, 1) + Font.draw(def.name, 72, 8) + local e = def.dexEntry or {} + Font.draw((e.kind or "?") .. " POKéMON", 72, 20) + Font.draw(("No.%03d"):format(def.dex or 0), 72, 32) + local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id] + -- height/weight print only once owned, like the description + -- (pokedex.asm: "if the pokemon has not been owned, don't print the + -- height, weight, or description") + if owned and e.heightFt then + -- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in + -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via + -- engine/gfx/load_pokedex_tiles.asm) + Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44) + Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54) + end + local text = owned and e.text and self.game.data.text[e.text] or nil + local y = 72 + if text then + for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do + if y > 132 then break end + Font.draw(line, 8, y) + y = y + 10 + end + else + Font.draw("Data unknown.", 8, y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return DexEntryMenu diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua new file mode 100644 index 00000000..fb434023 --- /dev/null +++ b/src/ui/EvolutionState.lua @@ -0,0 +1,93 @@ +-- The evolution movie (engine/movie/evolution.asm): the mon's pic +-- flashes back and forth with the evolved form, speeding up, then the +-- new form appears with its cry and the congratulations text. +-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1 +-- has no cancel; the flash always completes). + +local Font = require("src.render.Font") + +local EvolutionState = {} +EvolutionState.__index = EvolutionState +EvolutionState.isOpaque = true + +-- SGB: SetPal_PokemonWholeScreen for the mon on display +function EvolutionState:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local species = self.done and self.newSpecies or self.mon.species + local c = P.monPal(game.data, species) + if c then return { P.whole(c) } end + return P.wholeNamed(game.data, "MEWMON") +end + +local FLASH_FRAMES = 220 + +local function frontSprite(game, species) + local def = game.data.pokemon[species] + if not (def and def.spriteFront) then return nil end + local ok, img = pcall(love.graphics.newImage, def.spriteFront) + return ok and img or nil +end + +function EvolutionState.new(game, mon, newSpecies, onDone) + local self = setmetatable({}, EvolutionState) + self.game = game + self.mon = mon + self.newSpecies = newSpecies + self.onDone = onDone + self.oldName = mon.nickname or game.data.pokemon[mon.species].name + self.oldSprite = frontSprite(game, mon.species) + self.newSprite = frontSprite(game, newSpecies) + self.t = 0 + self.done = false + return self +end + +function EvolutionState:update(dt) + self.t = self.t + 1 + if self.done then return end + if self.t >= FLASH_FRAMES then + self.done = true + local game = self.game + local Evolution = require("src.pokemon.Evolution") + Evolution.apply(game, self.mon, self.newSpecies) + require("src.core.Sound").playCry(game.data, self.newSpecies) + local TextBox = require("src.render.TextBox") + local newName = game.data.pokemon[self.newSpecies].name + game.stack:push(TextBox.new(game, + ("Congratulations!\nYour %s\nevolved into\n%s!") + :format(self.oldName, newName), + function() + game.stack:pop() -- the evolution screen itself + if self.onDone then self.onDone() end + end)) + end +end + +function EvolutionState:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + + -- accelerating flash between the two forms + local sprite + if self.done then + sprite = self.newSprite + else + local period = math.max(4, 28 - math.floor(self.t / 40) * 6) + local showNew = math.floor(self.t / period) % 2 == 1 + sprite = showNew and self.newSprite or self.oldSprite + end + if sprite then + love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2), + math.max(8, 64 - sprite:getHeight())) + end + + love.graphics.setColor(0, 0, 0, 1) + if not self.done then + Font.draw("What?", 8, 104) + Font.draw(self.oldName .. " is", 8, 114) + Font.draw("evolving!", 8, 124) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return EvolutionState diff --git a/src/ui/FlyMenu.lua b/src/ui/FlyMenu.lua new file mode 100644 index 00000000..3ab4d694 --- /dev/null +++ b/src/ui/FlyMenu.lua @@ -0,0 +1,29 @@ +-- Fly destination picker: visited towns, landing at the real fly-warp +-- spots from data/maps/special_warps.asm. + +local ListMenu = require("src.ui.ListMenu") + +local FlyMenu = {} + +function FlyMenu.new(game) + local items = {} + local visited = game.save.visited or {} + for _, mapId in ipairs(game.data.field.flyOrder) do + -- towns only (dungeon escape spots share the table) + if visited[mapId] and game.data.maps[mapId] + and game.data.maps[mapId].tileset == "OVERWORLD" then + table.insert(items, { + value = mapId, + label = mapId:gsub("_", " "), + }) + end + end + return ListMenu.new(game, "FLY TO?", items, { + onChoose = function(item, list) + list:close() + game.overworld:flyTo(item.value) + end, + }) +end + +return FlyMenu diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua new file mode 100644 index 00000000..40883f8e --- /dev/null +++ b/src/ui/HallOfFame.lua @@ -0,0 +1,194 @@ +-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party +-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's +-- .ScrollPic), then its name/level shows and its cry plays +-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats +-- shows the trainer name, play time, money and Prof. Oak's dex rating. +-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after +-- popping itself. + +local Font = require("src.render.Font") +local Music = require("src.core.Music") +local Sound = require("src.core.Sound") + +local HallOfFame = {} +HallOfFame.__index = HallOfFame +HallOfFame.isOpaque = true + +-- SGB: SetPal_PokemonWholeScreen for the mon on display +function HallOfFame:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local mon = game.save.party[self.index or 0] + if mon then + local c = P.monPal(game.data, mon.species) + if c then return { P.whole(c) } end + return nil + end + return P.wholeNamed(game.data, "MEWMON") +end + +local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early) + +-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per +-- DelayFrame (doubled on SGB) until it settles. The back pic (an +-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left +-- and off the left edge first; tracing the actual hSCX/hSCY math shows +-- the real front pic that follows enters from the *left* edge and +-- slides *right* into its resting tile, at that same 4px/frame rate -- +-- that's the half we port here (the back-pic wipe is a VRAM/scroll- +-- register trick with no equivalent in this sprite-based renderer). +local SCROLL_SPEED = 4 -- px/frame @ 60fps + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +-- POKéDEX rating tiers (engine/events/pokedex_rating.asm DexRatingsTable) +local function dexRatingKey(owned) + if owned >= 150 then return "_DexRatingText_Own150To151" end + local lo = math.floor(owned / 10) * 10 + return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9) +end + +-- \n/\v/\f-marked extracted text, one Font.draw line at a time (same +-- technique as DexEntryMenu.lua's dex-description block) +local function drawTextBlock(text, x, y, maxY) + for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do + if maxY and y > maxY then break end + Font.draw(line, x, y) + y = y + 10 + end + return y +end + +function HallOfFame.new(game, onDone) + local self = setmetatable({}, HallOfFame) + self.game = game + self.onDone = onDone + self.index = 0 + self.timer = 0 + self.phase = "mons" + self.sprites = {} -- species -> image or false + return self +end + +function HallOfFame:enter() + local data = self.game.data + if data.audio and data.audio.songs and data.audio.songs.Music_HallOfFame then + pcall(Music.play, data, "Music_HallOfFame") + end + self:nextMon() +end + +function HallOfFame:nextMon() + self.index = self.index + 1 + local mon = self.game.save.party[self.index] + if mon then + self.timer = MON_FRAMES + Sound.playCry(self.game.data, mon.species) + -- scroll the new inductee's pic in from the left (see SCROLL_SPEED) + local sprite = self:spriteFor(mon.species) + local w = sprite and sprite:getWidth() or 0 + self.scrollRestX = math.floor((160 - w) / 2) + self.scrollX = -w + else + self.phase = "congrats" + end +end + +function HallOfFame:spriteFor(species) + local cached = self.sprites[species] + if cached == nil then + local def = self.game.data.pokemon[species] + cached = tryImage(def and def.spriteFront) or false + self.sprites[species] = cached + end + return cached or nil +end + +-- HoFDisplayPlayerStats' DisplayDexRating tally (also +-- OverworldController:dexRating / PokedexMenu.new's seen+owned counts) +function HallOfFame:dexSeenOwned() + local dex = self.game.save.pokedex or { seen = {}, owned = {} } + local seen, owned = 0, 0 + for _ in pairs(dex.seen or {}) do seen = seen + 1 end + for _ in pairs(dex.owned or {}) do owned = owned + 1 end + return seen, owned +end + +function HallOfFame:update(dt) + local input = self.game.input + if self.phase == "mons" then + if self.scrollX and self.scrollX < self.scrollRestX then + self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED) + end + self.timer = self.timer - 1 + if input:wasPressed("a") or self.timer <= 0 then + self:nextMon() + end + elseif input:wasPressed("a") then + Sound.play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +function HallOfFame:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + if self.phase == "mons" then + Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8) + local mon = self.game.save.party[self.index] + if mon then + local def = self.game.data.pokemon[mon.species] + love.graphics.setColor(1, 1, 1, 1) + local sprite = self:spriteFor(mon.species) + if sprite then + local w, h = sprite:getDimensions() + love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h) + end + love.graphics.setColor(0, 0, 0, 1) + local name = mon.nickname or (def and def.name) or mon.species + Font.draw(name, 32, 108) + Font.draw((":L%d"):format(mon.level), 112, 108) + end + else + -- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real + -- screen is a fresh ClearScreen): trainer name, play time, money, + -- then the POKéDEX seen/owned tally and Prof. Oak's rating text, + -- using the same real save-data fields as TrainerCard.lua + -- (save.player.name/playTime/money) and PokedexMenu.lua/ + -- OverworldController:dexRating (save.pokedex.seen/owned). + local save = self.game.save + local text = self.game.data.text or {} + local y = 8 + Font.draw(save.player.name or "RED", 8, y) + y = y + 16 + local t = math.floor(save.playTime or 0) + Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600), + math.floor(t / 60) % 60), 8, y) + y = y + 12 + Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y) + y = y + 16 + + local seen, owned = self:dexSeenOwned() + local seenOwned = text._DexSeenOwnedText + or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" + seenOwned = seenOwned + :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) + :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) + y = drawTextBlock(seenOwned, 8, y) + 6 + + local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") + Font.draw(ratingHeader, 8, y) + y = y + 12 + + local rating = text[dexRatingKey(owned)] or "Keep it up!" + drawTextBlock(rating, 8, y, 136) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return HallOfFame diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua new file mode 100644 index 00000000..3cab4224 --- /dev/null +++ b/src/ui/IntroMovie.lua @@ -0,0 +1,369 @@ +-- Boot splash + attract movie, a faithful port of PlayIntro +-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm) +-- using the real extracted art (data/generated/field.lua `intro` manifest). +-- +-- Three frame-counted phases: +-- 1. copyright card, 180 frames (intro.asm:311-312). +-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then +-- the big star streaks down-left for 40 frames while the GAME FREAK +-- logo + letters sit at (72,56)/(40,80) (splash.asm:27-60, 211-228), +-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small +-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower +-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold +-- (intro.asm:329-331). +-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played +-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG +-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left +-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted +-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap, +-- retreat, crouch and final lunge, ending in a 24-frame fade to white +-- (GBFadeOutToWhite, home/fade.asm:26-40). +-- +-- Any of A/B/START skips the whole movie (CheckForUserInterruption). +-- Pops itself and calls onDone() when finished or skipped. All art loads +-- through pcall and every missing graphic degrades to a text/rect +-- fallback, so the movie stays headless-safe. + +local Font = require("src.render.Font") +local Music = require("src.core.Music") +local Sound = require("src.core.Sound") + +local IntroMovie = {} +IntroMovie.__index = IntroMovie +IntroMovie.isOpaque = true + +-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo +-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract +-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars) +function IntroMovie:sgbPalettes(game) + local P = require("src.render.PaletteFX") + if self.phase == 2 then + local logo = P.pal(game.data, "GAMEFREAK") + if not logo then return nil end + return { + P.whole(logo), + P.zone(P.pal(game.data, "REDMON"), 5, 11, 7, 13), + P.zone(P.pal(game.data, "VIRIDIAN"), 8, 11, 9, 13), + P.zone(P.pal(game.data, "BLUEMON"), 12, 11, 14, 13), + } + elseif self.phase == 3 then + local purple = P.pal(game.data, "PURPLEMON") + if not purple then return nil end + return { + P.zone(P.pal(game.data, "BLACK"), 0, 0, 19, 3), + P.zone(purple, 0, 4, 19, 13), + P.zone(P.pal(game.data, "BLACK"), 0, 14, 19, 17), + } + end + return nil -- the copyright card stays plain +end + +local COPYRIGHT_FRAMES = 180 -- ld c, 180 (intro.asm:311-312) + +-- phase 2 (shooting star) timeline, in frames from phase start +local STAR_START = 64 -- ld c, 64 (intro.asm:323-324) +local STAR_FRAMES = 40 -- OAM Y 0->160 in +4 steps (splash.asm:32-60) +local FLASH_START = STAR_START + STAR_FRAMES +local FLASH_FRAMES = 30 -- 3 loops x 10 frames (splash.asm:72-82) +local WAVES_START = FLASH_START + FLASH_FRAMES +local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209) +local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty +local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) + +-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15 +-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets +-- cancel) +local LOGO_X, LOGO_Y = 72, 56 +local TEXT_X, TEXT_Y = 40, 80 + +-- the 4 waves of small stars: screen X positions, all spawning at y=88 +-- (OAM $68; SmallStarsWave*Coords, splash.asm:160-183) +local STAR_WAVES = { + { 40, 56, 80, 112 }, + { 48, 64, 88, 104 }, + { 44, 68, 76, 92 }, + { 52, 84, 100, 108 }, +} + +-- Nidorino movement lists: {dy, dx} applied every 5 frames +-- (AnimateIntroNidorino, intro.asm:143-158) +local ANIM = { + -- IntroNidorinoAnimation1..7 (intro.asm:370-437) + { {0,0}, {-2,2}, {-1,2}, {1,2}, {2,2} }, -- 1: hop arc, +8 right + { {0,0}, {-2,-2}, {-1,-2}, {1,-2}, {2,-2} }, -- 2: hop arc, -8 left + { {0,0}, {-12,6}, {-8,6}, {8,6}, {12,6} }, -- 3: dodge leap, +24 right + { {0,0}, {-8,-4}, {-4,-4}, {4,-4}, {8,-4} }, -- 4: high hop, -16 left + { {0,0}, {-8,4}, {-4,4}, {4,4}, {8,4} }, -- 5: high hop, +16 right + { {0,0}, {2,0}, {2,0}, {0,0} }, -- 6: crouch, +4 down + { {-8,-16}, {-7,-14}, {-6,-12}, {-4,-10} }, -- 7: lunge, -52/-25 up-left +} + +-- PlayIntroScene, in source order (intro.asm:23-141). `move` ops shift +-- 2px per 2 frames (IntroMoveMon, intro.asm:235-269): "scrollIn" moves +-- Nidorino right AND Gengar left together (the fallthrough at :247-259), +-- gengar dx<0 = MOVE_GENGAR_LEFT (SCX+2), dx>0 = MOVE_GENGAR_RIGHT. +local FIGHT_SCRIPT = { + { move = "scrollIn", px = 80 }, -- intro.asm:40-41 + { sfx = "Intro_Hip" }, { anim = 1 }, -- :44-50 + { sfx = "Intro_Hop" }, { anim = 2 }, { wait = 10 }, -- :51-57 + { sfx = "Intro_Hip" }, { anim = 1 }, -- :60-64 + { sfx = "Intro_Hop" }, { anim = 2 }, { wait = 30 }, -- :65-71 + { pose = 2 }, { sfx = "Intro_Raise" }, -- :74-78 + { move = "gengar", dx = -8 }, { wait = 30 }, -- :79-82 + { pose = 3 }, { sfx = "Intro_Crash" }, -- :85-89 + { move = "gengar", dx = 16 }, -- :90-91 + { sfx = "Intro_Hip" }, { frame = 2 }, { anim = 3 }, -- :92-98 + { wait = 30 }, -- :99-100 + { move = "gengar", dx = -8 }, { pose = 1 }, -- :103-106 + { wait = 60 }, -- :107-108 + { sfx = "Intro_Hip" }, { frame = 1 }, { anim = 4 }, -- :111-117 + { sfx = "Intro_Hop" }, { anim = 5 }, { wait = 20 }, -- :118-124 + { frame = 2 }, { anim = 6 }, { wait = 30 }, -- :127-132 + { sfx = "Intro_Lunge" }, { frame = 3 }, { anim = 7 }, -- :135-141 + { fade = 24 }, -- GBFadeOutToWhite: 3 pals x 8 frames (home/fade.asm:26-40) +} + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +function IntroMovie.new(game, onDone) + local self = setmetatable({}, IntroMovie) + self.game = game + self.onDone = onDone + self.phase = 1 + self.timer = 0 + self.finished = false + + local intro = game.data.field and game.data.field.intro or {} + local function img(e) return tryImage(e and e.path) end + self.copyright = tryImage("assets/generated/title/copyright.png") + self.logo = img(intro.gamefreakLogo) + self.gfText = img(intro.gamefreakText) + self.bigStar = img(intro.bigStar) + self.smallStar = img(intro.fallingStar) + self.smallStarBlink = img(intro.fallingStarBlink) + self.gengarFrames, self.nidoFrames = {}, {} + for i = 1, 3 do + self.gengarFrames[i] = img(intro.gengar and intro.gengar["frame" .. i]) + self.nidoFrames[i] = img(intro.nidorino and intro.nidorino["frame" .. i]) + end + + -- fight state (PlayIntroScene entry, intro.asm:30-39): Gengar BG pose at + -- tile (13,7) = screen (104,56); Nidorino OAM base (0,80) = screen + -- (-8,72) after the OAM +8 offsets + self.gengarX, self.gengarY = 104, 56 + self.nidoX, self.nidoY = -8, 72 + self.gengarPose, self.nidoFrame = 1, 1 + self.opIndex, self.opTimer = 1, 0 + self.fade = 0 + return self +end + +function IntroMovie:finish() + if self.finished then return end + self.finished = true + pcall(Music.stop) + self.game.stack:pop() + if self.onDone then self.onDone() end +end + +function IntroMovie:startPhase(phase) + self.phase = phase + self.timer = 0 + if phase == 3 then + -- intro.asm:333-338 + local data = self.game.data + local songs = data.audio and data.audio.songs + if songs and songs.Music_IntroBattle then + pcall(Music.play, data, "Music_IntroBattle", false) + end + end +end + +-- one frame of the fight script (see FIGHT_SCRIPT) +function IntroMovie:fightStep() + while true do + local op = FIGHT_SCRIPT[self.opIndex] + if not op then + self:finish() + return + end + if op.sfx then + Sound.play(self.game.data, op.sfx) + elseif op.pose then + self.gengarPose = op.pose + elseif op.frame then + self.nidoFrame = op.frame + elseif op.move then + -- 2px per 2 frames (IntroMoveMon: CheckForUserInterruption c=2) + if self.opTimer % 2 == 0 then + if op.move == "scrollIn" then + self.gengarX = self.gengarX - 2 + self.nidoX = self.nidoX + 2 + else + self.gengarX = self.gengarX + (op.dx > 0 and 2 or -2) + end + end + self.opTimer = self.opTimer + 1 + if self.opTimer < (op.px or math.abs(op.dx)) then return end + elseif op.anim then + -- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5) + if self.opTimer % 5 == 0 then + local d = ANIM[op.anim][self.opTimer / 5 + 1] + self.nidoY = self.nidoY + d[1] + self.nidoX = self.nidoX + d[2] + end + self.opTimer = self.opTimer + 1 + if self.opTimer < #ANIM[op.anim] * 5 then return end + elseif op.wait then + self.opTimer = self.opTimer + 1 + if self.opTimer < op.wait then return end + elseif op.fade then + self.opTimer = self.opTimer + 1 + self.fade = self.opTimer / op.fade + if self.opTimer >= op.fade then self:finish() end + return + end + self.opIndex = self.opIndex + 1 + self.opTimer = 0 + end +end + +function IntroMovie:update(dt) + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") then + self:finish() + return + end + self.timer = self.timer + 1 + if self.phase == 1 then + if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end + elseif self.phase == 2 then + if self.timer == STAR_START then + Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30 + end + if self.timer >= SPLASH_FRAMES then self:startPhase(3) end + else + self:fightStep() + end +end + +-- the letterbox bars: 4 black tile rows top and bottom +-- (IntroDrawBlackBars, intro.asm:343-357); drawn AFTER the sprites since +-- both Nidorino and the small stars carry OAM_PRIO (intro.asm:195, +-- splash.asm:149) so the bars cover them. +local function drawBars() + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 160, 32) + love.graphics.rectangle("fill", 0, 112, 160, 32) + love.graphics.setColor(1, 1, 1, 1) +end + +function IntroMovie:drawSplash() + local t = self.timer + if t >= STAR_START then + -- logo + GAME FREAK letters appear with the star OAM + -- (LoadShootingStarGraphics, splash.asm:18-25); the logo palette + -- rotates during the 3-flash loop (splash.asm:72-82) + local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES + local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0 + love.graphics.setColor(1, 1, 1, dim and 0.35 or 1) + if self.logo then + love.graphics.draw(self.logo, LOGO_X, LOGO_Y) + end + -- custom studio name (replaces the GAME FREAK splash text) + love.graphics.setColor(0, 0, 0, dim and 0.35 or 1) + Font.draw("bois club games", (160 - 15 * 8) / 2, TEXT_Y) + love.graphics.setColor(1, 1, 1, 1) + end + if t >= STAR_START and t < FLASH_START then + -- big star: from OAM (160,0) moving +4Y/-4X per frame + -- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60) + local n = t - STAR_START + 1 + local sx, sy = 152 - 4 * n, -16 + 4 * n + if self.bigStar then + love.graphics.draw(self.bigStar, sx, sy) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + end + if t >= WAVES_START then + -- small stars: wave w spawns at y=88 every 24 frames, everything falls + -- +1px per 3-frame substep until the wave loop ends; the lower star in + -- the tile blinks every substep (splash.asm:97-146, 186-209) + local substep = math.floor((math.min(t, WAVES_END) - WAVES_START) / 3) + local blink = substep % 2 == 0 + for w, xs in ipairs(STAR_WAVES) do + local spawn = (w - 1) * 8 -- in substeps + if substep >= spawn then + local y = 88 + (substep - spawn) + if y < 144 then + local img = blink and self.smallStar + or (self.smallStarBlink or self.smallStar) + for _, x in ipairs(xs) do + if img then + love.graphics.draw(img, x, y) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 3, y + 1, 2, 2) + love.graphics.setColor(1, 1, 1, 1) + end + end + end + end + end + end + drawBars() +end + +function IntroMovie:drawFight() + -- Gengar: a 56x56 BG-tile pose recomposed from gengar_N.tilemap, moved + -- by scrolling SCX (intro.asm:32-33, 235-269) + -- Nidorino: 6x6 OAM sprite, one of the three red_nidorino poses + local nido = self.nidoFrames[self.nidoFrame] + if nido then + love.graphics.draw(nido, self.nidoX, self.nidoY) + end + local gengar = self.gengarFrames[self.gengarPose] + if gengar then + love.graphics.draw(gengar, self.gengarX, self.gengarY) + end + + if not gengar and not nido then + love.graphics.setColor(0, 0, 0, 1) + Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64) + love.graphics.setColor(1, 1, 1, 1) + end + drawBars() + if self.fade > 0 then + love.graphics.setColor(1, 1, 1, math.min(1, self.fade)) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function IntroMovie:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + if self.phase == 1 then + -- custom boot card (replaces the Nintendo / GAME FREAK copyright + -- card; no (c) glyph in the charmap, keep it ASCII-safe) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("2026", (160 - 4 * 8) / 2, 48) + Font.draw("bois club", (160 - 9 * 8) / 2, 64) + Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) + elseif self.phase == 2 then + self:drawSplash() + else + self:drawFight() + end + love.graphics.setColor(1, 1, 1, 1) +end + +return IntroMovie diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua new file mode 100644 index 00000000..476298a8 --- /dev/null +++ b/src/ui/ListMenu.lua @@ -0,0 +1,161 @@ +-- Generic full-screen scrollable list: items are { label=..., right=..., +-- value=... }; onChoose(item) / onCancel(). Used by the bag, shops, the +-- box and the Pokédex. + +local Font = require("src.render.Font") + +local ListMenu = {} +ListMenu.__index = ListMenu +ListMenu.isOpaque = true + +-- SGB: generic whole-screen palette (SET_PAL_GENERIC) +function ListMenu:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +local CURSOR = 0xED +local ROWS = 7 + +function ListMenu.new(game, title, items, opts) + opts = opts or {} + local self = setmetatable({}, ListMenu) + self.game = game + self.title = title + self.items = items + self.index = 1 + self.scroll = 0 + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.footer = opts.footer + self.pageJump = opts.pageJump -- Left/Right move a page at a time + self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item + -- scripted mode (the old man tutorial): update() runs the script + -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man + -- branch (home/list_menu.asm:65-80) never calls HandleMenuInput + self.script = opts.script + -- shop mode: the footer becomes the clerk's line in a framed bottom + -- text box, a money box sits top-right, and the list shortens to + -- clear them (DisplayPokemartDialogue_'s screen) + self.dialogue = opts.dialogue + self.money = opts.money -- () -> current money for the box + self.rows = opts.dialogue and 4 or ROWS + return self +end + +function ListMenu:update(dt) + if self.script then + self.script(self) + return + end + local input = self.game.input + if #self.items == 0 then + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + end + return + end + if input:wasPressed("up") then + self.index = math.max(1, self.index - 1) + elseif input:wasPressed("down") then + self.index = math.min(#self.items, self.index + 1) + elseif self.pageJump and input:wasPressed("left") then + self.index = math.max(1, self.index - self.rows) + elseif self.pageJump and input:wasPressed("right") then + self.index = math.min(#self.items, self.index + self.rows) + elseif self.onSelectKey and input:wasPressed("select") then + self.onSelectKey(self.items[self.index], self) + elseif input:wasPressed("b") then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif input:wasPressed("a") then + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end + return + end + if self.index - self.scroll > self.rows then + self.scroll = self.index - self.rows + end + if self.index - self.scroll < 1 then self.scroll = self.index - 1 end +end + +-- remove current item (e.g. consumed); keeps cursor valid +function ListMenu:removeCurrent() + table.remove(self.items, self.index) + self.index = math.max(1, math.min(self.index, #self.items)) +end + +function ListMenu:close() + local top = self.game.stack:top() + if top == self then self.game.stack:pop() end +end + +function ListMenu:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.title, 8, 4) + if #self.items == 0 then + Font.draw("Nothing here.", 16, 64) + end + for row = 1, self.rows do + local i = self.scroll + row + local item = self.items[i] + if not item then break end + local y = 8 + row * 16 + Font.draw(item.label, 16, y) + if item.ball then -- the Pokédex owned-ball marker tile + local bx = 16 + (#item.label + 1) * 8 + 3 + local by = y + 3 + love.graphics.circle("fill", bx, by, 3.5) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1) + love.graphics.circle("fill", bx, by, 1.2) + love.graphics.setColor(0, 0, 0, 1) + end + if item.right then + Font.draw(item.right, 160 - 8 - #item.right * 8, y) + end + if i == self.index then + -- hollowIndex: a chosen row keeps the hollow '▷' left behind by + -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's + -- auto A-press, home/list_menu.asm:89-91) + Font.drawCode((self.swapIndex == i or self.hollowIndex == i) + and 0xEC or CURSOR, 8, y) + end + if self.swapIndex == i and i ~= self.index then + Font.drawCode(0xEC, 8, y) -- ▷ marks the item being moved + end + end + if self.dialogue then + -- money box (DisplayTextBoxID MONEY_BOX, hlcoord 11,0): the amount + -- right-aligned on its middle row + Font.drawBox(11, 0, 9, 3) + love.graphics.setColor(0, 0, 0, 1) + local money = ("¥%d"):format(self.money and self.money() or 0) + Font.draw(money, 152 - #money * 8, 8) + -- the clerk's line in the standard bottom text box; long prompts + -- wrap and keep their last two lines, like the GB's scrolled box + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if self.footer then + local flat = {} + for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + local y = 112 + for i = math.max(1, #flat - 1), #flat do + Font.draw(flat[i], 8, y) + y = y + 16 + end + end + elseif self.footer then + Font.draw(self.footer, 8, 136) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return ListMenu diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua new file mode 100644 index 00000000..6c01fae3 --- /dev/null +++ b/src/ui/Menu.lua @@ -0,0 +1,77 @@ +-- Generic bordered list menu with the blinking ▶ cursor. +-- items: { { label=..., onSelect=function }, ... } +-- Pops itself on B (unless cancelable=false); also on START only when +-- opts.startCloses is set -- pokered's wMenuWatchedKeys mask varies per +-- menu and only the start menu's adds PAD_START. + +local Font = require("src.render.Font") + +local Menu = {} +Menu.__index = Menu + +local CURSOR = 0xED -- "▶" glyph (right arrow) in font.png + +function Menu.new(game, items, opts) + local self = setmetatable({}, Menu) + opts = opts or {} + self.game = game + self.items = items + self.index = 1 + self.tx = opts.tx or 10 + self.ty = opts.ty or 0 + self.tw = opts.tw or 10 + self.th = opts.th or (#items * 2 + 2) + self.cancelable = opts.cancelable ~= false + -- Whether START closes the menu. In pokered a menu responds only to the + -- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the + -- list menu's PAD_A | PAD_B | PAD_SELECT) masks leave START unwatched, so + -- only menus whose real mask includes PAD_START -- the start menu + -- (engine/menus/draw_start_menu.asm) -- opt in here. + self.startCloses = opts.startCloses or false + self.onCancel = opts.onCancel + -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its + -- menus silent (home/window.asm HandleMenuInput_) + self.noSound = opts.noSound or false + return self +end + +function Menu:update(dt) + local input = self.game.input + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.items + elseif input:wasPressed("down") then + self.index = self.index < #self.items and self.index + 1 or 1 + elseif input:wasPressed("a") then + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then self.game.stack:pop() end + if item.onSelect then item.onSelect() end + elseif self.cancelable and (input:wasPressed("b") + or (self.startCloses and input:wasPressed("start"))) then + -- HandleMenuInput_ returns for any watched key, but only replays + -- SFX_PRESS_AB for the PAD_A | PAD_B branch -- so B beeps and START + -- (when watched, e.g. the start menu) closes silently. + if input:wasPressed("b") and not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then self.onCancel() end + end +end + +function Menu:draw() + Font.drawBox(self.tx, self.ty, self.tw, self.th) + love.graphics.setColor(0, 0, 0, 1) + for i, item in ipairs(self.items) do + Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8) + end + Font.drawCode(CURSOR, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8) + love.graphics.setColor(1, 1, 1, 1) +end + +return Menu diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua new file mode 100644 index 00000000..3a16d5de --- /dev/null +++ b/src/ui/MoveLearnMenu.lua @@ -0,0 +1,133 @@ +-- "Which move should be forgotten?", replaces a move when a Pokémon with +-- four moves learns a new one (engine/pokemon/learn_move.asm). Opens +-- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves +-- can't be forgotten; B / CANCEL gives up on the new move. + +local Font = require("src.render.Font") + +local MoveLearnMenu = {} +MoveLearnMenu.__index = MoveLearnMenu + +local CURSOR = 0xED + +-- data/moves/hm_moves.asm (IsMoveHM) +local HM_MOVES = { + CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, +} + +function MoveLearnMenu.new(game, mon, newMoveId, onDone) + local self = setmetatable({}, MoveLearnMenu) + self.game = game + self.mon = mon + self.newMoveId = newMoveId + self.onDone = onDone + self.index = 1 + return self +end + +function MoveLearnMenu:monName() + return self.mon.nickname or self.game.data.pokemon[self.mon.species].name +end + +-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers +-- AbandonLearning, whose own NO loops back here (DontAbandonLearning). +function MoveLearnMenu:enter() + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local game = self.game + local mdef = game.data.moves[self.newMoveId] + local name = self:monName() + game.stack:push(TextBox.new(game, + ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") + :format(name, mdef.name, name) .. + ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), + function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then self:confirmAbandon() end + end)) + end)) +end + +function MoveLearnMenu:update(dt) + local input = self.game.input + local n = #self.mon.moves + 1 -- moves + CANCEL + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or n + elseif input:wasPressed("down") then + self.index = self.index < n and self.index + 1 or 1 + elseif input:wasPressed("b") then + self:confirmAbandon() + elseif input:wasPressed("a") then + if self.index > #self.mon.moves then + self:confirmAbandon() + else + local old = self.mon.moves[self.index] + if HM_MOVES[old.id] then + -- HMCantDeleteText, then back to the forget list + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + "HM techniques\ncan't be deleted!")) + return + end + local mdef = self.game.data.moves[self.newMoveId] + self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp } + self.forgot = self.game.data.moves[old.id].name + self:finish(true) + end + end +end + +-- AbandonLearning (learn_move.asm): "Abandon learning MOVE?" YES/NO +-- before giving up; NO returns to the TryingToLearn prompt +-- (DontAbandonLearning) +function MoveLearnMenu:confirmAbandon() + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local game = self.game + local mdef = game.data.moves[self.newMoveId] + game.stack:push(TextBox.new(game, + ("Abandon learning\n%s?"):format(mdef.name), function() + game.stack:push(ChoiceBox.new(game, function(yes) + if yes then self:finish(false) else self:enter() end + end)) + end)) +end + +function MoveLearnMenu:finish(learned) + local TextBox = require("src.render.TextBox") + local game = self.game + local name = self:monName() + local mdef = game.data.moves[self.newMoveId] + game.stack:pop() + local msg + if learned then + -- OneTwoAndText/PoofText/ForgotAndText + msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!") + :format(name, self.forgot, name, mdef.name) + else + -- DidNotLearnText + msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name) + end + game.stack:push(TextBox.new(game, msg, function() + if self.onDone then self.onDone(learned) end + end)) +end + +function MoveLearnMenu:draw() + -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) + -- plus the port's extra CANCEL row + Font.drawBox(4, 5, 16, 7) + love.graphics.setColor(0, 0, 0, 1) + for i, mv in ipairs(self.mon.moves) do + Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8) + end + Font.draw("CANCEL", 48, (6 + #self.mon.moves) * 8) + Font.drawCode(CURSOR, 40, (5 + self.index) * 8) + -- WhichMoveToForgetText in the bottom dialogue box + Font.drawBox(0, 12, 20, 6) + Font.draw("Which move should", 8, 14 * 8) + Font.draw("be forgotten?", 8, 16 * 8) + love.graphics.setColor(1, 1, 1, 1) +end + +return MoveLearnMenu diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua new file mode 100644 index 00000000..7ce415b1 --- /dev/null +++ b/src/ui/NamingScreen.lua @@ -0,0 +1,162 @@ +-- Gen 1 letter-grid naming screen (engine/menus/naming_screen.asm). +-- Full gen-1 glyph grid (data/text/alphabets.asm): five 9-cell rows +-- ending in ED, plus a case-switch row. A picks a letter, B deletes, +-- SELECT flips case, START or the ED cell confirms. If opts.presets is +-- given, a "NEW NAME" + presets menu is shown first +-- (engine/menus/main_menu.asm name lists). +-- Pops itself from the stack, then calls opts.onDone(name). + +local Font = require("src.render.Font") +local Sound = require("src.core.Sound") + +local NamingScreen = {} +NamingScreen.__index = NamingScreen +NamingScreen.isOpaque = true + +-- SGB: generic whole-screen palette (SET_PAL_GENERIC) +function NamingScreen:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +local CURSOR = 0xED + +-- both letter pages (wAlphabetCase, data/text/alphabets.asm): row 6 is +-- the case-switch cell, labelled with the page it flips to +local GRID_UPPER = { + { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, + { "J", "K", "L", "M", "N", "O", "P", "Q", "R" }, + { "S", "T", "U", "V", "W", "X", "Y", "Z", " " }, + { "×", "(", ")", ":", ";", "[", "]", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "lower case" }, +} +local GRID_LOWER = { + { "a", "b", "c", "d", "e", "f", "g", "h", "i" }, + { "j", "k", "l", "m", "n", "o", "p", "q", "r" }, + { "s", "t", "u", "v", "w", "x", "y", "z", " " }, + { "×", "(", ")", ":", ";", "[", "]", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "UPPER CASE" }, +} +local CASE_ROW = 6 +local ED_ROW, ED_COL = 5, 9 + +function NamingScreen.new(game, opts) + opts = opts or {} + local self = setmetatable({}, NamingScreen) + self.game = game + self.title = opts.title or "YOUR NAME?" + self.presets = opts.presets + self.maxLen = opts.maxLen or 7 + self.default = opts.default + self.onDone = opts.onDone + self.glyphs = {} -- typed glyphs; multi-byte cells (, ♂, ×) count as 1 + self.row, self.col = 1, 1 + self.lower = false + return self +end + +function NamingScreen:enter() + if self.presets and #self.presets > 0 then + local Menu = require("src.ui.Menu") + local items = { { label = "NEW NAME" } } + for _, preset in ipairs(self.presets) do + table.insert(items, { + label = preset, + onSelect = function() + -- the menu already popped itself; pop the naming screen too + self.game.stack:pop() + if self.onDone then self.onDone(preset) end + end, + }) + end + self.game.stack:push(Menu.new(self.game, items, { + tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false, + })) + end +end + +function NamingScreen:confirm() + local name = table.concat(self.glyphs) + if name == "" then + name = (self.presets and self.presets[1]) or self.default or "A" + end + Sound.play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then self.onDone(name) end +end + +function NamingScreen:grid() + return self.lower and GRID_LOWER or GRID_UPPER +end + +-- Gen 1 jumps the cursor to ED once the name is full. +function NamingScreen:jumpToEnd() + self.row, self.col = ED_ROW, ED_COL +end + +function NamingScreen:update(dt) + local GRID = self:grid() + local input = self.game.input + if input:wasPressed("start") then + self:confirm() + return + end + if input:wasPressed("select") then -- SELECT also flips the case page + self.lower = not self.lower + return + end + if input:wasPressed("up") then + -- wrapping up from the top row lands on the case-switch cell + self.row = self.row > 1 and self.row - 1 or CASE_ROW + self.col = math.min(self.col, #GRID[self.row]) + elseif input:wasPressed("down") then + self.row = self.row < #GRID and self.row + 1 or 1 + self.col = math.min(self.col, #GRID[self.row]) + elseif input:wasPressed("left") then + -- no horizontal movement on the case-switch row + if self.row ~= CASE_ROW then + self.col = self.col > 1 and self.col - 1 or #GRID[self.row] + end + elseif input:wasPressed("right") then + if self.row ~= CASE_ROW then + self.col = self.col < #GRID[self.row] and self.col + 1 or 1 + end + elseif input:wasPressed("b") then + table.remove(self.glyphs) + elseif input:wasPressed("a") then + if self.row == ED_ROW and self.col == ED_COL then + self:confirm() + return + end + if self.row == CASE_ROW then + self.lower = not self.lower + return + end + if #self.glyphs < self.maxLen then + Sound.play(self.game.data, "Press_AB") + table.insert(self.glyphs, GRID[self.row][self.col]) + if #self.glyphs >= self.maxLen then self:jumpToEnd() end + end + end +end + +function NamingScreen:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.title, 8, 8) + -- typed name with dashes for the empty slots + for i = 1, self.maxLen do + Font.draw(self.glyphs[i] or "-", 56 + (i - 1) * 8, 24) + end + for r, row in ipairs(self:grid()) do + for c, cell in ipairs(row) do + Font.draw(cell, c * 16, 32 + r * 16) + end + end + Font.drawCode(CURSOR, self.col * 16 - 8, 32 + self.row * 16) + love.graphics.setColor(1, 1, 1, 1) +end + +return NamingScreen diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua new file mode 100644 index 00000000..9fd182f2 --- /dev/null +++ b/src/ui/OakSpeech.lua @@ -0,0 +1,254 @@ +-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's +-- welcome, the NIDORINO show-off, player and rival naming, and the +-- closing "legend is about to unfold" text followed by the shrink-away: +-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the +-- overworld walking sprite before the fade to white. Uses the real +-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText, +-- _IntroduceRivalText) with literal fallbacks. +-- Calls onDone() after popping itself. + +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") +local TextBox = require("src.render.TextBox") +local Font = require("src.render.Font") + +local OakSpeech = {} +OakSpeech.__index = OakSpeech +OakSpeech.isOpaque = true + +-- SGB: generic whole-screen palette (SET_PAL_GENERIC) +function OakSpeech:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +local FALLBACKS = { + _OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!", + _OakSpeechText2A = "This world is\ninhabited by\vcreatures called\vPOKéMON!", + _OakSpeechText2B = "\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession.", + _OakSpeechText3 = "{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!", + _IntroducePlayerText = "First, what is\nyour name?", + _IntroduceRivalText = "This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?", +} + +local function textOr(game, key) + local t = game.data.text + return (t and t[key]) or FALLBACKS[key] +end + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +function OakSpeech.new(game, onDone) + local self = setmetatable({}, OakSpeech) + self.game = game + self.onDone = onDone + self.step = 0 + self.pic = nil + local trainers = game.data.trainers or {} + self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) + self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) + local nido = game.data.pokemon and game.data.pokemon.NIDORINO + self.nidorinoPic = tryImage(nido and nido.spriteFront) + -- RedPicFront (gfx/player/red.png, shared with the trainer card) and + -- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) + self.playerPic = tryImage("assets/generated/trainer_card/red.png") + local oakGfx = game.data.field and game.data.field.oakSpeech + self.shrinkPic1 = tryImage(oakGfx and oakGfx.shrink1 + or "assets/generated/intro/shrink1.png") + self.shrinkPic2 = tryImage(oakGfx and oakGfx.shrink2 + or "assets/generated/intro/shrink2.png") + -- RedSprite: the walking sprite the pic shrinks into (frame 0 = + -- standing, facing down) + local red = game.data.sprites and game.data.sprites.SPRITE_RED + self.walkSheet = tryImage(red and red.image) + return self +end + +function OakSpeech:enter() + -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) + Music.play(self.game.data, "Music_Routes2") + self:advance() +end + +function OakSpeech:say(key, next) + self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next)) +end + +local STEPS = { + -- 1. Oak's welcome + function(self) + self.pic = self.oakPic + self:say("_OakSpeechText1", function() self:advance() end) + end, + -- 2. NIDORINO show-off, with its cry + function(self) + self.pic = self.nidorinoPic + Sound.playCry(self.game.data, "NIDORINO") + self:say("_OakSpeechText2A", function() self:advance() end) + end, + -- 3. the rest of the world-of-POKéMON spiel + function(self) + self:say("_OakSpeechText2B", function() self:advance() end) + end, + -- 4. "First, what is your name?" over the player's own pic + -- (RedPicFront, oak_speech.asm:86-91) then the naming screen + function(self) + self.pic = self.playerPic or self.oakPic + self:say("_IntroducePlayerText", function() self:advance() end) + end, + function(self) + local NamingScreen = require("src.ui.NamingScreen") + self.game.stack:push(NamingScreen.new(self.game, { + title = "YOUR NAME?", + presets = { "RED", "ASH", "JACK" }, + maxLen = 7, + onDone = function(name) + self.game.save.player.name = name + self:advance() + end, + })) + end, + -- 6. the rival introduction and naming + function(self) + self.pic = self.rivalPic + self:say("_IntroduceRivalText", function() self:advance() end) + end, + function(self) + local NamingScreen = require("src.ui.NamingScreen") + self.game.stack:push(NamingScreen.new(self.game, { + title = "HIS NAME?", + presets = { "BLUE", "GARY", "JOHN" }, + maxLen = 7, + onDone = function(name) + self.game.save.player.rival = name + self:advance() + end, + })) + end, + -- 8. "your very own POKéMON legend is about to unfold!" over the + -- player pic again (oak_speech.asm:105-113) + function(self) + self.pic = self.playerPic or self.oakPic + self:say("_OakSpeechText3", function() self:advance() end) + end, + -- 9. SFX_SHRINK: the pic collapses through the two shrink frames + -- into the walking sprite, then fades to white (oak_speech.asm + -- .next, lines 115-166). Not skippable, like the DelayFrames + -- chain it ports. + function(self) + Sound.play(self.game.data, "Shrink") + -- the OakSpeechText3 box holds its last page on screen through the + -- shrink (pokered text boxes persist until overwritten) + self.shrinkText = self:lastPageLines("_OakSpeechText3") + self.shrink = { frame = 0 } + end, +} + +-- the last two visible lines of a text's final page, pre-encoded +function OakSpeech:lastPageLines(key) + local ok, lines = pcall(function() + local text = TextBox.substitute(self.game, textOr(self.game, key)) + local pages = TextBox.paginate(text) + local page = pages[#pages] + local out = {} + for i = math.max(1, #page - 1), #page do + out[#out + 1] = Font.encode(page[i]) + end + return out + end) + return ok and lines or nil +end + +function OakSpeech:advance() + self.step = self.step + 1 + local fn = STEPS[self.step] + if fn then + fn(self) + else + self:finish() + end +end + +function OakSpeech:finish() + -- the map theme starts with the overworld beneath (the original's + -- special warp into Pallet Town) + local ow = self.game.overworld + local mapId = (ow and ow.map and ow.map.id) + or (self.game.save.player and self.game.save.player.map) + if mapId then Music.playMap(self.game.data, mapId) end + self.game.stack:pop() + if self.onDone then self.onDone() end +end + +-- Shrink timeline (oak_speech.asm .next): +-- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames) +-- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames) +-- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20) +-- frames 29-78 pic area cleared, walking sprite at the standard +-- player screen spot (ResetPlayerSpriteData / +-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50) +-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames) +function OakSpeech:update(dt) + if not self.shrink then return end + local s = self.shrink + s.frame = s.frame + 1 + if s.frame == 5 then + self.pic = self.shrinkPic1 or self.pic + elseif s.frame == 9 then + self.pic = self.shrinkPic2 or self.pic + -- wAudioFadeOutControl = 10: the music ramps to silence over ~70 + -- frames (7 levels x 10), reaching 0 just as the fade-to-white + -- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149, + -- home/fade_audio.asm) + Music.fadeOut(10) + elseif s.frame == 29 then + self.pic = nil + self.walkVisible = true + elseif s.frame >= 79 and s.frame <= 102 then + self.fadeLevel = math.floor((s.frame - 79) / 8) + 1 + elseif s.frame > 102 then + self:finish() + end +end + +function OakSpeech:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + if self.pic then + -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic + -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside + -- 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 + local y = 32 + (7 - h / 8) * 8 + love.graphics.draw(self.pic, x, y) + end + if self.walkVisible and self.walkSheet then + -- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40 + self.walkQuad = self.walkQuad + or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions()) + love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60) + end + if self.shrinkText then + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + for i, line in ipairs(self.shrinkText) do + local y = (12 + 2 * i) * 8 + for j, code in ipairs(line) do + Font.drawCode(code, 8 + (j - 1) * 8, y) + end + end + love.graphics.setColor(1, 1, 1, 1) + end + if self.fadeLevel then + love.graphics.setColor(1, 1, 1, self.fadeLevel / 3) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) + end +end + +return OakSpeech diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua new file mode 100644 index 00000000..8384fa95 --- /dev/null +++ b/src/ui/OptionsMenu.lua @@ -0,0 +1,191 @@ +-- Options: text speed, battle animation on/off, battle style SHIFT/SET +-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset +-- (gen1_faithful keeps the original quirks; modern_clean removes the +-- 1/256 miss etc), plus the port's audio rows and display rows: music/SFX +-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / +-- GBC FX. +-- Option boxes scroll through a four-box viewport; CANCEL stays fixed on +-- the bottom line like pokered's. + +local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") +local Tilt = require("src.render.Tilt") +local GBCFX = require("src.render.GBCFX") + +local OptionsMenu = {} +OptionsMenu.__index = OptionsMenu +OptionsMenu.isOpaque = true + +local CURSOR = 0xED -- "▶" (charmap.asm $ED) +local DOWN_ARROW = 0xEE -- "▼" (charmap.asm $EE): more rows below +-- TextSpeedOptionData frame delays with the original labels +local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } +local RULES = { "gen1_faithful", "modern_clean" } +local FILTERS = { "OFF", "1X", "2X", "3X" } +-- 3 original options + OG GLITCHES / MUSIC VOL / SFX VOL / MUSIC FILTER +-- + COLORS / TILT / GBC FX + CANCEL +local OPTION_ROWS = 10 +local ROWS = 11 +local CANCEL_ROW = 11 +local VISIBLE = 4 -- option boxes on screen at once (4 tiles each) + +function OptionsMenu.new(game) + return setmetatable({ game = game, index = 1, scroll = 0 }, OptionsMenu) +end + +local function speedIndex(game) + -- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions + local cur = game.save.options.textSpeed or 3 + for i, s in ipairs(SPEEDS) do + if s[1] == cur then return i end + end + return 2 -- MEDIUM +end + +-- 0-7 volume level display (0 = OFF) +local function volLabel(v) + v = v or 7 + return v == 0 and "OFF" or tostring(v) +end + +-- volume rows clamp at the ends, like pokered's text-speed cursor +-- (.pressedLeftInTextSpeed stays at FAST rather than wrapping) +local function stepVolume(v, dir) + return math.max(0, math.min(7, (v or 7) + dir)) +end + +local function colorIndex(opts) + local cur = opts.colors or "gbc" + for i, m in ipairs(PaletteFX.MODES) do + if m == cur then return i end + end + return 1 +end + +local function wrapIndex(i, n) + i = i % n + if i < 0 then i = i + n end + return i +end + +local function stepColors(opts, dir) + local i = colorIndex(opts) + i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 + opts.colors = PaletteFX.MODES[i] + PaletteFX.setMode(opts.colors) +end + +local function stepTilt(opts, dir) + opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4) + Tilt.setLevel(opts.tilt) +end + +local function stepGbcfx(opts, dir) + opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) + GBCFX.setLevel(opts.gbcfx) +end + +function OptionsMenu:update(dt) + local input = self.game.input + local opts = self.game.save.options + local changed = false + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or ROWS + elseif input:wasPressed("down") then + self.index = self.index < ROWS and self.index + 1 or 1 + elseif input:wasPressed("left") or input:wasPressed("right") + or input:wasPressed("a") then + local dir = input:wasPressed("left") and -1 or 1 + if self.index == 1 then + local i = speedIndex(self.game) % #SPEEDS + 1 + opts.textSpeed = SPEEDS[i][1] + changed = true + elseif self.index == 2 then + opts.animations = opts.animations == false and true or false + changed = true + elseif self.index == 3 then + opts.battleStyle = opts.battleStyle == "set" and "shift" or "set" + changed = true + elseif self.index == 4 then + opts.ruleset = opts.ruleset == RULES[1] and RULES[2] or RULES[1] + changed = true + elseif self.index == 5 then + opts.musicVol = stepVolume(opts.musicVol, dir) + require("src.core.Music").setVolumeLevel(opts.musicVol) + changed = true + elseif self.index == 6 then + opts.sfxVol = stepVolume(opts.sfxVol, dir) + require("src.core.Sound").setVolumeLevel(opts.sfxVol) + changed = true + elseif self.index == 7 then + opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS + require("src.core.Music").setFilterLevel(opts.musicFilter) + changed = true + elseif self.index == 8 then + stepColors(opts, dir) + changed = true + elseif self.index == 9 then + stepTilt(opts, dir) + changed = true + elseif self.index == 10 then + stepGbcfx(opts, dir) + changed = true + elseif input:wasPressed("a") then -- CANCEL + self.game.stack:pop() + end + elseif input:wasPressed("b") or input:wasPressed("start") then + self.game.stack:pop() + end + if changed and self.game.writeOptions then + self.game:writeOptions() + end + -- keep the cursor's box inside the viewport; CANCEL shows the tail + if self.index >= CANCEL_ROW then + self.scroll = OPTION_ROWS - VISIBLE + elseif self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE then + self.scroll = self.index - VISIBLE + end +end + +function OptionsMenu:draw() + local opts = self.game.save.options + -- one bordered box per option, label line + value line, with CANCEL + -- below (main_menu.asm DisplayOptionMenu layout, extended with the + -- port's rows; a ▼ marks option boxes scrolled off below) + local rows = { + { "TEXT SPEED", SPEEDS[speedIndex(self.game)][2] }, + { "BATTLE ANIMATION", opts.animations == false and "OFF" or "ON" }, + { "BATTLE STYLE", opts.battleStyle == "set" and "SET" or "SHIFT" }, + { "OG GLITCHES", opts.ruleset == "modern_clean" and "OFF" or "ON" }, + { "MUSIC VOL", volLabel(opts.musicVol) }, + { "SFX VOL", volLabel(opts.sfxVol) }, + { "MUSIC FILTER", FILTERS[(opts.musicFilter or 0) + 1] }, + { "COLORS", PaletteFX.modeLabel(opts.colors or "gbc") }, + { "TILT", Tilt.levelLabel(opts.tilt or 0) }, + { "GBC FX", GBCFX.levelLabel(opts.gbcfx or 0) }, + } + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local scroll = self.scroll or 0 + for slot = 1, VISIBLE do + local i = scroll + slot + local row = rows[i] + Font.drawBox(0, (slot - 1) * 4, 20, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(row[1], 16, ((slot - 1) * 4 + 1) * 8) + Font.draw(row[2], 24, ((slot - 1) * 4 + 2) * 8) + if i == self.index then + Font.drawCode(CURSOR, 8, ((slot - 1) * 4 + 1) * 8) + end + end + if scroll + VISIBLE < #rows then + Font.drawCode(DOWN_ARROW, 144, 128) + end + Font.draw("CANCEL", 16, 136) + if self.index == CANCEL_ROW then Font.drawCode(CURSOR, 8, 136) end + love.graphics.setColor(1, 1, 1, 1) +end + +return OptionsMenu diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua new file mode 100644 index 00000000..11f46b2d --- /dev/null +++ b/src/ui/PartyMenu.lua @@ -0,0 +1,428 @@ +-- Party menu: list the party, choose a member. +-- Modes: +-- default: A -> submenu (STATS / SWITCH order / CANCEL) +-- opts.onSwitch: A -> hand the chosen mon to the callback (battle +-- switch, item targeting via opts.pickOnly) +-- opts.onCancel: fired when the menu closes without a pick (B) +-- Pops itself on B. + +local Font = require("src.render.Font") + +local PartyMenu = {} +PartyMenu.__index = PartyMenu +PartyMenu.isOpaque = true + +-- SGB: generic whole-screen palette (SET_PAL_GENERIC) +function PartyMenu:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +local CURSOR = 0xED + +-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is +-- excluded by map id in ItemUseEscapeRope) +local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, + FACILITY = true, INTERIOR = true } + +-- Party mon icons (engine/gfx/mon_icons.asm AnimatePartyMon): only the +-- SELECTED mon's icon animates, at a speed set by its HP bar color -- +-- 5 / 16 / 32 frames per phase for green / yellow / red (the famous +-- health-speed detail). BALL and HELIX icons nudge one pixel down +-- instead of switching frames; every other icon swaps to a real second +-- frame (+ICONOFFSET). + +-- Rest/alt frame per icon (data/icon_pointers.asm +-- MonPartySpritePointers): the base entries are the RESTING frame, +-- the +ICONOFFSET entries the animated alternate. The 16x32 icon +-- sheets stack Frame1 (index 0) over Frame2 (index 1, INC_FRAME_2): +-- BUG/GRASS rest on BugIconFrame2/PlantIconFrame2 and animate to +-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw +-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12): +-- MON/FAIRY/BIRD rest on the walk frame and animate to standing +-- (tile 0); WATER (Seel) is the reverse. +PartyMenu.iconFrames = { + BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1 + GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1 + SNAKE = { rest = 0, alt = 1 }, -- SnakeIconFrame1 <-> SnakeIconFrame2 + QUADRUPED = { rest = 0, alt = 1 }, -- QuadrupedIconFrame1 <-> Frame2 + MON = { rest = 3, alt = 0 }, -- MonsterSprite tile 12 <-> tile 0 + FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0 + BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0 + WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12 +} + +-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel +-- height) only matters for the fallback, which keeps the old uniform +-- behavior for icons outside the table (BALL/HELIX y-bob instead). +function PartyMenu.frameFor(name, alt, ih) + local m = PartyMenu.iconFrames[name] + if m then return alt and m.alt or m.rest end + return alt and ((ih or 0) >= 64 and 3 or 1) or 0 +end + +local iconImages = {} +local function drawIcon(game, mon, x, y, selected, counter) + local icons = game.data.icons + if not icons then return end + local def = game.data.pokemon[mon.species] + local name = def and def.dex and icons.byDex[def.dex] + local path = name and icons.icons[name] + if not path then return end + if iconImages[path] == nil then + local ok, img = pcall(love.graphics.newImage, path) + iconImages[path] = ok and img or false + end + local img = iconImages[path] + if not img then return end + local alt = false + 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 + alt = math.floor(counter / speed) % 2 == 1 + end + if alt and (name == "BALL" or name == "HELIX") then + y = y + 1 + alt = false + end + local iw, ih = img:getDimensions() + if ih > 16 then + local frame = PartyMenu.frameFor(name, alt, ih) + love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y) + else + love.graphics.draw(img, x, y) + end +end + +function PartyMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PartyMenu) + self.game = game + self.index = 1 + self.onSwitch = opts.onSwitch + self.onCancel = opts.onCancel + self.pickOnly = opts.pickOnly + self.battle = opts.battle + self.party = opts.party -- link battles pass their clamped copies + self.swapFrom = nil + self.submenu = nil + self.subIndex = 1 + self.blink = 0 + return self +end + +function PartyMenu:update(dt) + -- icon animation counter; 320 = a whole cycle at every HP speed + self.blink = ((self.blink or 0) + 1) % 320 + local input = self.game.input + local party = self.party or self.game.save.party + + if self.submenu then + local n = #self.subItems + if input:wasPressed("up") then + self.subIndex = self.subIndex > 1 and self.subIndex - 1 or n + elseif input:wasPressed("down") then + self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 + elseif input:wasPressed("b") then + self.submenu = nil + elseif input:wasPressed("a") then + local mon = party[self.index] + local action = self.subItems[self.subIndex].action + if action == "stats" then + local SummaryMenu = require("src.ui.SummaryMenu") + self.game.stack:push(SummaryMenu.new(self.game, mon)) + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "fly" then + local FlyMenu = require("src.ui.FlyMenu") + self.game.stack:pop() -- close the party menu + self.game.stack:push(FlyMenu.new(self.game)) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + self.game.stack:pop() + ow.dark = false + self.game.save.flashLit = true + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._FlashLightsAreaText + or "A blinding FLASH\nlights the area!", function() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + self.game.stack:pop() -- close the party menu (jp .goBackToMap) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press + -- steps the player forward onto land + self.game.stack:pop() + ow.player.surfing = false + require("src.core.Music").setSurfing(self.game.data, false) + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + ow:scriptMove(ow.player, ow.player.facing, 1) + end)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or "No SURFing here!") + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap) + self.game.stack:pop() + self.game.stack:push(TextBox.new(self.game, txt, function() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or "Nothing to CUT!") + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UsedStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _CanMoveBouldersText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + local def = self.game.data.pokemon[mon.species] + local name = mon.nickname or def.name + self.game.stack:pop() -- close the party menu (jp .goBackToMap) + ow.strengthActive = true + local t1 = (self.game.data.text._UsedStrengthText + or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name) + local t2 = (self.game.data.text._CanMoveBouldersText + or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name) + self.game.stack:push(TextBox.new(self.game, t1, function() + self.game.stack:push(TextBox.new(self.game, t2, function() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(self.game.data, mon.species) + end } })) + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "escape" then + -- DIG / TELEPORT both warp to the last Pokémon Center town + -- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport + -- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap + local ow = self.game.overworld + local heal = self.game.save.lastHeal + local Transition = require("src.render.Transition") + self.game.stack:pop() + if ow and heal then + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + require("src.core.Sound").play(self.game.data, "Teleport_Exit1") + ow:warpToHealPoint() + end)) + end + return + end + self.submenu = nil + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or math.max(1, #party) + elseif input:wasPressed("down") then + self.index = self.index < #party and self.index + 1 or 1 + elseif input:wasPressed("b") then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + elseif input:wasPressed("a") and #party > 0 then + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect.")) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + ("%s's HP\nwas restored!"):format(mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch then + self.game.stack:pop() + self.onSwitch(mon) + else + self.submenu = true + self.subIndex = 1 + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + self.subItems = { { label = "STATS", action = "stats" }, + { label = "SWITCH", action = "switch" } } + local ow = self.game.overworld + if not self.battle and ow and mon.hp > 0 then + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" + and self.game.save.inventory.THUNDERBADGE then + table.insert(self.subItems, { label = "FLY", action = "fly" }) + elseif mv.id == "FLASH" and ow.dark + and self.game.save.inventory.BOULDERBADGE then + table.insert(self.subItems, { label = "FLASH", action = "flash" }) + elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(self.subItems, { label = "CUT", action = "cut" }) + elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then + table.insert(self.subItems, { label = "SURF", action = "surf" }) + elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then + table.insert(self.subItems, { label = "STRENGTH", action = "strength" }) + elseif mv.id == "SOFTBOILED" then + table.insert(self.subItems, { label = "SOFTBOILED", action = "softboiled" }) + elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(self.subItems, { label = "TELEPORT", action = "escape" }) + elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] + and ow.map.id ~= "AGATHAS_ROOM" then + -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel) + table.insert(self.subItems, { label = "DIG", action = "escape" }) + end + end + end + end + end +end + +function PartyMenu:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + local party = self.party or self.game.save.party + if #party == 0 then + Font.draw("No POKéMON!", 16, 64) + end + local HudTiles = require("src.render.HudTiles") + for i, mon in ipairs(party) do + local def = self.game.data.pokemon[mon.species] + local y = (i - 1) * 16 + 12 + love.graphics.setColor(1, 1, 1, 1) + drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(mon.nickname or def.name, 24, y) + -- level at column 13 ( tile + digits, PrintLevel) AND the + -- status/FNT text at column 17 (PrintStatusCondition), like the + -- original rows -- statused mons keep their level display + if mon.level < 100 then + HudTiles.tile(0x6E, 104, y) -- + Font.draw(tostring(mon.level), 112, y) + else + -- PrintLevel overwrites the tile with the third digit + Font.draw(tostring(mon.level), 104, y) + end + if mon.hp <= 0 then + Font.draw("FNT", 136, y) + elseif mon.status then + Font.draw(mon.status, 136, y) + end + -- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor) + love.graphics.setColor(1, 1, 1, 1) + HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) + if i == self.index then + Font.drawCode(CURSOR, 0, y) + end + if i == self.swapFrom or i == self.softboiledFrom then + Font.drawCode(0xEC, 0, y) -- the unfilled swap arrow + end + end + if self.swapFrom then + Font.draw("Move to where?", 8, 136) + elseif self.softboiledFrom then + Font.draw("Use on which one?", 8, 136) + elseif self.pickOnly then + Font.draw("Use on which one?", 8, 136) + end + if self.submenu then + local n = #self.subItems + Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1) + local y0 = (17 - n * 2) * 8 + for si, entry in ipairs(self.subItems) do + Font.draw(entry.label, 88, y0 + (si - 1) * 16) + end + Font.drawCode(CURSOR, 80, y0 + (self.subIndex - 1) * 16) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return PartyMenu diff --git a/src/ui/PicBox.lua b/src/ui/PicBox.lua new file mode 100644 index 00000000..ac396295 --- /dev/null +++ b/src/ui/PicBox.lua @@ -0,0 +1,41 @@ +-- A framed picture pop-up (DisplayMonFrontSpriteInBox): shows an image +-- in a bordered box over the screen; any button closes it, then the +-- optional text plays. + +local Font = require("src.render.Font") + +local PicBox = {} +PicBox.__index = PicBox +PicBox.isOpaque = false + +function PicBox.new(game, imagePath, text) + local self = setmetatable({}, PicBox) + self.game = game + local ok, img = pcall(love.graphics.newImage, imagePath) + self.image = ok and img or nil + self.text = text + return self +end + +function PicBox:update(dt) + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.text then + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, self.text)) + end + end +end + +function PicBox:draw() + Font.drawBox(6, 4, 9, 9) + if self.image then + love.graphics.setColor(1, 1, 1, 1) + local w, h = self.image:getDimensions() + love.graphics.draw(self.image, math.floor((6 + 4.5) * 8 - w / 2), + math.floor((4 + 4.5) * 8 - h / 2)) + end +end + +return PicBox diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua new file mode 100644 index 00000000..9467d5fb --- /dev/null +++ b/src/ui/PlayerPC.lua @@ -0,0 +1,162 @@ +-- The player's item-storage PC (engine/menus/players_pc.asm): +-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over +-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and +-- deposit ask "How many?" via the quantity selector (key items always +-- move one); toss discards after a YES/NO confirm. Follows the +-- BoxMenu/BagMenu list idioms. + +local ChoiceBox = require("src.ui.ChoiceBox") +local ListMenu = require("src.ui.ListMenu") +local Menu = require("src.ui.Menu") +local Sound = require("src.core.Sound") + +local PlayerPC = {} + +local function itemName(game, id) + local def = game.data.items[id] + return def and def.name or id +end + +local function buildItems(game, store) + local items = {} + local ids = {} + for id in pairs(store) do table.insert(ids, id) end + table.sort(ids) + for _, id in ipairs(ids) do + table.insert(items, { + value = id, + label = itemName(game, id), + right = "x" .. store[id], + }) + end + return items +end + +-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText → +-- DisplayChooseQuantityMenu) capped at the stack count. Key items and +-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm). +-- cb(qty) runs only on confirm. +local function askQuantity(game, list, count, id, cb) + local def = game.data.items[id] + if (def and def.keyItem) or id:find("^HM_") then + cb(1) + return + end + list.footer = "How many?" + local QuantityBox = require("src.ui.QuantityBox") + game.stack:push(QuantityBox.new(game, { + max = count, + onDone = function(qty) + if qty then cb(qty) else list.footer = nil end + end, + })) +end + +-- refresh the chosen row's count from `store` (or drop the row) +local function refreshRow(list, store, id) + for i, it in ipairs(list.items) do + if it.value == id then + if store[id] then + it.right = "x" .. store[id] + else + table.remove(list.items, i) + end + break + end + end + list.index = math.max(1, math.min(list.index, #list.items)) +end + +local function withdraw(game) + local pc = game.save.pcItems + game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + onChoose = function(item, list) + askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) + local Bag = require("src.inventory.Bag") + if not Bag.add(game.save, item.value, qty) then + list.footer = "You can't carry\nany more items." + return + end + pc[item.value] = pc[item.value] - qty + if pc[item.value] <= 0 then pc[item.value] = nil end + refreshRow(list, pc, item.value) + Sound.play(game.data, "Withdraw_Deposit") + list.footer = ("Withdrew\n%s."):format(itemName(game, item.value)) + end) + end, + })) +end + +-- wNumBoxItems capacity: 50 stacks (PC_ITEM_CAPACITY) +local function pcFull(game, pc, id) + if pc[id] then return false end -- growing an existing stack is fine + local cap = game.data.field.pcItemCap or 50 + local stacks = 0 + for _ in pairs(pc) do stacks = stacks + 1 end + return stacks >= cap +end + +local function deposit(game) + local pc = game.save.pcItems + local inv = game.save.inventory + game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), { + onChoose = function(item, list) + askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) + if pcFull(game, pc, item.value) then + list.footer = "No room left to\nstore items." + return + end + require("src.inventory.Bag").remove(game.save, item.value, qty) + pc[item.value] = (pc[item.value] or 0) + qty + refreshRow(list, inv, item.value) + Sound.play(game.data, "Withdraw_Deposit") + list.footer = ("%s was\nstored via PC."):format(itemName(game, item.value)) + end) + end, + })) +end + +local function toss(game) + local pc = game.save.pcItems + game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + onChoose = function(item, list) + local def = game.data.items[item.value] + if (def and def.keyItem) or item.value:find("^HM_") then + list.footer = "That's too impor-\ntant to toss!" + return + end + local QuantityBox = require("src.ui.QuantityBox") + game.stack:push(QuantityBox.new(game, { + max = pc[item.value] or 1, + onDone = function(qty) + if not qty then return end + list.footer = ("Toss %s?"):format(itemName(game, item.value)) + game.stack:push(ChoiceBox.new(game, function(yes) + if yes then + pc[item.value] = pc[item.value] - qty + if pc[item.value] <= 0 then pc[item.value] = nil end + refreshRow(list, pc, item.value) + list.footer = ("Threw away %s."):format(itemName(game, item.value)) + else + list.footer = nil + end + end, { noSound = true })) + end, + })) + end, + })) +end + +function PlayerPC.new(game) + game.save.pcItems = game.save.pcItems or {} + return Menu.new(game, { + { label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end }, + { label = "DEPOSIT ITEM", onSelect = function() deposit(game) end }, + { label = "TOSS ITEM", onSelect = function() toss(game) end }, + { label = "LOG OFF" }, + -- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND, + -- engine/menus/players_pc.asm PlayersPCMenu) + }, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true }) +end + +return PlayerPC diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua new file mode 100644 index 00000000..b3bf8988 --- /dev/null +++ b/src/ui/PokedexMenu.lua @@ -0,0 +1,72 @@ +-- Minimal Pokédex: dex-ordered list with seen/owned markers. + +local ListMenu = require("src.ui.ListMenu") + +local PokedexMenu = {} + +-- SGB: PalPacket_Pokedex, whole screen +function PokedexMenu:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON") +end + +function PokedexMenu.new(game) + local dex = game.save.pokedex or { seen = {}, owned = {} } + local byDex = {} + for species, def in pairs(game.data.pokemon) do + if def.dex then byDex[def.dex] = def end + end + local items = {} + local seen, owned = 0, 0 + for n = 1, 151 do + local def = byDex[n] + if def then + local label + if dex.owned[def.id] then + label = ("%03d %s"):format(n, def.name) + owned = owned + 1 + seen = seen + 1 + elseif dex.seen[def.id] then + label = ("%03d %s"):format(n, def.name) + seen = seen + 1 + else + label = ("%03d -----"):format(n) + end + table.insert(items, { + label = label, + -- owned entries carry the pokéball marker like the original + -- list; seen-only entries are just the name + ball = dex.owned[def.id] or nil, + value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil, + }) + end + end + local list = ListMenu.new(game, "POKéDEX", items, { + footer = ("SEEN %d OWNED %d"):format(seen, owned), + pageJump = true, -- Left/Right page jumps like the original + onChoose = function(item) + if not item.value then return end + -- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm + -- PokedexMenuItemsText); CRY keeps the side menu open like the + -- original, QUIT returns to the list + local Menu = require("src.ui.Menu") + game.stack:push(Menu.new(game, { + { label = "DATA", onSelect = function() + local DexEntryMenu = require("src.ui.DexEntryMenu") + game.stack:push(DexEntryMenu.new(game, item.value)) + end }, + { label = "CRY", keepOpen = true, onSelect = function() + require("src.core.Sound").playCry(game.data, item.value) + end }, + { label = "AREA", onSelect = function() + local TownMap = require("src.ui.TownMap") + game.stack:push(TownMap.new(game, { nestSpecies = item.value })) + end }, + { label = "QUIT" }, + }, { tx = 12, ty = 8, tw = 8, th = 10 })) + end, + }) + list.sgbPalettes = PokedexMenu.sgbPalettes + return list +end + +return PokedexMenu diff --git a/src/ui/QuantityBox.lua b/src/ui/QuantityBox.lua new file mode 100644 index 00000000..e7a73705 --- /dev/null +++ b/src/ui/QuantityBox.lua @@ -0,0 +1,55 @@ +-- The "how many?" selector (DisplayChooseQuantityMenu, home/list_menu.asm): +-- Up/Down step by 1 with 1..max roll-over, A confirms, B cancels. +-- Shows a running price when opts.unitPrice is set. + +local Font = require("src.render.Font") + +local QuantityBox = {} +QuantityBox.__index = QuantityBox +QuantityBox.isOpaque = false + +function QuantityBox.new(game, opts) + local self = setmetatable({}, QuantityBox) + self.game = game + self.max = math.max(1, opts.max or 99) + self.qty = math.min(opts.start or 1, self.max) + self.unitPrice = opts.unitPrice + self.onDone = opts.onDone -- onDone(qty | nil on cancel) + return self +end + +local function wrap(v, max) + if v < 1 then return max end + if v > max then return 1 end + return v +end + +function QuantityBox:update(dt) + local input = self.game.input + if input:wasPressed("up") then + self.qty = wrap(self.qty + 1, self.max) + elseif input:wasPressed("down") then + self.qty = wrap(self.qty - 1, self.max) + elseif input:wasPressed("a") then + self.game.stack:pop() + if self.onDone then self.onDone(self.qty) end + elseif input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone(nil) end + end +end + +function QuantityBox:draw() + local w = self.unitPrice and 11 or 7 + local tx = 20 - w - 1 + Font.drawBox(tx, 13, w, 3) + love.graphics.setColor(0, 0, 0, 1) + local s = ("×%02d"):format(self.qty) -- the multiply glyph tile + if self.unitPrice then + s = s .. (" ¥%d"):format(self.qty * self.unitPrice) + end + Font.draw(s, (tx + 1) * 8, 14 * 8) + love.graphics.setColor(1, 1, 1, 1) +end + +return QuantityBox diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua new file mode 100644 index 00000000..a315c111 --- /dev/null +++ b/src/ui/ShopMenu.lua @@ -0,0 +1,156 @@ +-- Mart shop (engine/events/pokemart.asm DisplayPokemartDialogue_): +-- the BUY/SELL/QUIT menu loops until QUIT -- BUY and SELL keep it on +-- the stack underneath their list, and QUIT hands control back to the +-- caller (open_mart resumes its yielded script runner there). Both +-- lists run in dialogue mode: the clerk speaks the real _Pokemart* +-- strings in the bottom text box with the money box top-right, then +-- the 1-99 quantity selector (DisplayChooseQuantityMenu) and a YES/NO +-- price confirm. Key items and HMs can't be sold (.unsellableItem). + +local Bag = require("src.inventory.Bag") +local ChoiceBox = require("src.ui.ChoiceBox") +local ListMenu = require("src.ui.ListMenu") +local Menu = require("src.ui.Menu") +local QuantityBox = require("src.ui.QuantityBox") + +local ShopMenu = {} + +local function txt(game, key, fallback) + return game.data.text[key] or fallback +end + +local function buy(game, stock) + local items = {} + for _, id in ipairs(stock) do + local def = game.data.items[id] + if def then + table.insert(items, { + value = id, + label = def.name, + right = ("¥%d"):format(def.price), + }) + end + end + local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") + local notEnough = txt(game, "_PokemartNotEnoughMoneyText", + "You don't have\nenough money.") + local list + list = ListMenu.new(game, "BUY", items, { + dialogue = true, + money = function() return game.save.money end, + footer = greet, + onChoose = function(item) + local def = game.data.items[item.value] + if game.save.money < def.price then + list.footer = notEnough + return + end + local affordable = math.min(99, math.floor(game.save.money / math.max(1, def.price))) + game.stack:push(QuantityBox.new(game, { + max = affordable, + unitPrice = def.price, + onDone = function(qty) + if not qty then + list.footer = greet + return + end + local cost = qty * def.price + -- _PokemartTellBuyPriceText + yes/no confirm + list.footer = ("%s?\nThat will be\n¥%d. OK?"):format(def.name, cost) + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then + list.footer = greet + return + end + if game.save.money < cost then + list.footer = notEnough + return + end + if not Bag.add(game.save, item.value, qty) then + list.footer = txt(game, "_PokemartItemBagFullText", + "You can't carry\nany more items.") + return + end + require("src.core.Sound").play(game.data, "Purchase") + game.save.money = game.save.money - cost + list.footer = txt(game, "_PokemartBoughtItemText", + "Here you are!\nThank you!") + end)) + end, + })) + end, + }) + game.stack:push(list) +end + +local function sell(game) + local items = {} + for _, id in ipairs(Bag.order(game.save)) do + local def = game.data.items[id] + table.insert(items, { + value = id, + label = (def and def.name or id) .. " x" .. game.save.inventory[id], + right = ("¥%d"):format(def and math.floor(def.price / 2) or 0), + }) + end + local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") + local list + list = ListMenu.new(game, "SELL", items, { + dialogue = true, + money = function() return game.save.money end, + footer = greet, + onChoose = function(item) + local def = game.data.items[item.value] + -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / + -- IsItemHM); zero-price items like ETHER sell for ¥0 + if (def and def.keyItem) or item.value:find("^HM_") then + list.footer = txt(game, "_PokemartUnsellableItemText", + "I can't put a\nprice on that.") + return + end + local unit = math.floor(def.price / 2) + game.stack:push(QuantityBox.new(game, { + max = game.save.inventory[item.value] or 1, + unitPrice = unit, + onDone = function(qty) + if not qty then + list.footer = greet + return + end + -- _PokemartTellSellPriceText + yes/no confirm + list.footer = ("I can pay you\n¥%d for that."):format(unit * qty) + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then + list.footer = greet + return + end + game.save.money = game.save.money + unit * qty + Bag.remove(game.save, item.value, qty) + local left = game.save.inventory[item.value] + if left then + item.label = def.name .. " x" .. left + else + list:removeCurrent() + end + list.footer = txt(game, "_PokemartThankYouText", "Thank you!") + end)) + end, + })) + end, + }) + game.stack:push(list) +end + +function ShopMenu.new(game, stock, onQuit) + -- keepOpen: the mart menu stays underneath its list so closing the + -- list lands back here; only QUIT (or B) leaves and fires onQuit + local menu = Menu.new(game, { + { label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end }, + { label = "SELL", keepOpen = true, onSelect = function() sell(game) end }, + { label = "QUIT", onSelect = onQuit }, + }, { tx = 0, ty = 0, tw = 8, th = 8 }) + menu.onCancel = onQuit + return menu +end + +return ShopMenu diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua new file mode 100644 index 00000000..4f218acb --- /dev/null +++ b/src/ui/SlotMachine.lua @@ -0,0 +1,736 @@ +-- Game Corner slot machine minigame. +-- +-- Wheels are the real symbol sequences (data/events/slot_machine_wheels.asm +-- via field.slotWheels: 15 symbols per wheel plus 3 wraparound entries, +-- read exactly like SlotMachine_GetWheelTiles). Wheel positions are kept +-- in pokered's half-symbol offsets (wSlotMachineWheelXOffset, 0..29): a +-- wheel may only stop when its offset is odd (a symbol is centred), and +-- every animation step advances the offset by one (SlotMachine_AnimWheel), +-- so slips scroll on screen tile-by-tile like the original. +-- +-- Per-wheel stop rules (engine/slots/slot_machine.asm): +-- * wheel 1 (SlotMachine_StopWheel1Early): at each centred position it +-- spends one of 4 slip charges (wSlotMachineWheel1SlipCounter); it stops +-- unless the centred middle symbol is a cherry, which it slips past. In +-- seven-and-bar mode the early-stop test is pokered's bug (`cp +-- HIGH(SLOTS7)` / `jr c`, never true), so it always slips all 4. +-- * wheel 2 (SlotMachine_StopWheel2Early): stops as soon as wheels 1 and 2 +-- line up any potential match (SlotMachine_FindWheel1Wheel2Matches); in +-- seven-and-bar mode it instead stops when the matched (or, with no +-- match, bottom) wheel-2 symbol is a 7 or BAR. Up to 4 slips. +-- * wheel 3 (SlotMachine_StopOrAnimWheel3): stops at the next centred +-- position; SlotMachine_CheckForMatches then rerolls it one symbol at a +-- time -- past any match the luck flags forbid (without consuming the +-- counter), or toward a match while wSlotMachineRerollCounter (4) lasts. +-- +-- Payouts/paylines follow SlotMachine_CheckForMatches: bet 1 plays the +-- middle row, bet 2 adds top and bottom, bet 3 adds both diagonals, +-- checked in pokered's order with the FIRST match taken; 7-7-7 pays 300, +-- BAR 100, CHERRY 8, anything else 15. +-- +-- Hidden luck (SlotMachine_SetFlags + game_corner_slots.asm): one machine +-- per Game Corner visit is "lucky" (seven-and-bar mode chance 5/256 vs +-- 2/256). Each spin: 1/256 arms a 60-charge allow-matches counter, +-- r > chance arms seven-and-bar mode (sticky until a BAR win clears it, or +-- a 300 win does so half the time), r in 211..chance allows a match, the +-- rest can't win. +-- +-- Presentation follows pokered's flow: PromptUserToPlaySlots asks "Want to +-- play?" first; MainSlotMachineLoop shows the static SlotMachineMap frame +-- (gfx/slots/slots.tilemap, via field.slotSymbols.tilemap), a "Bet how many +-- coins?" prompt with the ×3/×2/×1 menu (cursor defaults to ×3), flashes the +-- screen on a win (SlotReward*Func b flips of rBGP, 5 frames each) and drips +-- the payout one coin per 8 frames (4 for a 7/BAR) with a jingle and a +-- symbol-palette flicker, then asks "One more go?". + +local Font = require("src.render.Font") +local Sound = require("src.core.Sound") + +local SlotMachine = {} +SlotMachine.__index = SlotMachine +SlotMachine.isOpaque = true + +-- rBGP/rOBP0 `xor $40` from the default $e4 shows the darkest shade (3) one +-- step lighter (shade 2): the win-screen and payout flash +-- (SlotMachine_CheckForMatches .flashScreenLoop / SlotMachine_PayCoinsToPlayer). +local FLASH_MAP = { [0] = 0, [1] = 1, [2] = 2, [3] = 2 } + +-- SGB: PalPacket_Slots + BlkPacket_Slots row bands. While self.flash is set +-- the bands are permuted like pokered's rBGP flip so the machine flashes. +function SlotMachine:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local s1 = P.pal(game.data, "SLOTS1") + if not s1 then return nil end + -- self.flash "all" flips every band (the win-screen rBGP flash); "reels" + -- flips only the symbol window (the payout-time rOBP0 symbol flicker, which + -- the s1 zone at cols 4-15 / rows 4-9 covers). + local function fx(c, reel) + if c and self.flash and (self.flash == "all" or reel) then + return P.permute(c, FLASH_MAP) + end + return c + end + return { + P.zone(fx(P.pal(game.data, "SLOTS2")), 0, 0, 19, 11), + P.zone(fx(P.pal(game.data, "SLOTS3")), 0, 4, 19, 9), + P.zone(fx(P.pal(game.data, "SLOTS4")), 0, 6, 19, 7), + P.zone(fx(s1, true), 4, 4, 15, 9), + P.zone(fx(s1), 0, 12, 19, 17), + } +end + +local PAYOUT = { ["7"] = 300, BAR = 100, CHERRY = 8, + MOUSE = 15, FISH = 15, BIRD = 15 } +local SHORT = { ["7"] = " 7 ", BAR = "BAR", CHERRY = "CHR", + MOUSE = "MSE", FISH = "FSH", BIRD = "BRD" } + +-- MainSlotMachineLoop timing: one animation step every other frame +-- (DelayFrame in SlotMachine_HandleInputWhileWheelsSpin plus DelayFrames(1) +-- on SGB, which this port colorizes as). The initial free spin is 20 +-- steps at the same cadence (SlotMachine_SpinWheels .loop1). +local STEP_FRAMES = 2 +local SPINUP_STEPS = 20 + +local function at(wheel, pos, off) + return wheel[((pos + off - 1) % #wheel) + 1] +end + +-- The three visible symbols at a centred position: wheel[pos] (bottom), +-- wheel[pos+1] (middle), wheel[pos+2] (top) -- SlotMachine_GetWheelTiles. +local function rows(wheel, pos) + return at(wheel, pos, 0), at(wheel, pos, 1), at(wheel, pos, 2) +end + +-- Paylines in pokered's check order (SlotMachine_CheckForMatches): a +-- 3-coin bet tries both diagonals first, then falls into the 2-coin +-- checks (top row, bottom row), then the 1-coin middle row. The FIRST +-- matching line wins. Entries are row offsets from the bottom. +local LINES = { + { 0, 1, 2, bet = 3 }, -- wheel1 bottom / wheel2 middle / wheel3 top + { 2, 1, 0, bet = 3 }, -- wheel1 top / wheel2 middle / wheel3 bottom + { 2, 2, 2, bet = 2 }, -- top row + { 0, 0, 0, bet = 2 }, -- bottom row + { 1, 1, 1, bet = 1 }, -- middle row +} + +-- stops = {pos1, pos2, pos3} (1-based bottom-row positions); returns the +-- first matching line's payout+symbol, like SlotMachine_CheckForMatches. +function SlotMachine.evaluate(wheels, stops, bet) + for _, line in ipairs(LINES) do + if bet >= line.bet then + local a = at(wheels[1], stops[1], line[1]) + local b = at(wheels[2], stops[2], line[2]) + local c = at(wheels[3], stops[3], line[3]) + if a == b and b == c then + return { payout = PAYOUT[a] or 15, symbol = a } + end + end + end + return nil +end + +-- SlotMachine_StopWheel1Early: true = stop at this centred position. +-- Normally wheel 1 stops unless the centred middle symbol is a cherry. +-- In seven-and-bar mode pokered compares each visible tile with +-- `cp HIGH(SLOTS7)` / `jr c` -- never true, so it never stops early +-- (the wheel always slips through all four charges). +function SlotMachine.stopWheel1Early(wheels, pos1, sevenBar) + if sevenBar then return false end + local _, middle = rows(wheels[1], pos1) + return middle ~= "CHERRY" +end + +-- SlotMachine_FindWheel1Wheel2Matches: can wheels 1 and 2, as placed, +-- still line up a payline given a good wheel 3? Pairs are checked in +-- pokered's order: bottom/bottom, bottom/middle, middle/middle, +-- top/middle, top/top (wheel 1 row first). Returns matched plus the +-- wheel-2 tile DE points at afterwards (the matched tile, or wheel 2's +-- bottom tile when nothing matched). +function SlotMachine.findWheel1Wheel2Matches(wheels, pos1, pos2) + local b1, m1, t1 = rows(wheels[1], pos1) + local b2, m2, t2 = rows(wheels[2], pos2) + if b2 == b1 then return true, b2 end + if m2 == b1 then return true, m2 end + if m2 == m1 then return true, m2 end + if m2 == t1 then return true, m2 end + if t2 == t1 then return true, t2 end + return false, b2 +end + +-- SlotMachine_StopWheel2Early: true = stop at this centred position. +-- Normally wheel 2 stops as soon as any wheel-1/2 match is lined up; in +-- seven-and-bar mode it stops when the matched (or bottom, when nothing +-- matched) wheel-2 symbol is a 7 or BAR. +function SlotMachine.stopWheel2Early(wheels, pos1, pos2, sevenBar) + local matched, tile = SlotMachine.findWheel1Wheel2Matches(wheels, pos1, pos2) + if sevenBar then + return tile == "7" or tile == "BAR" + end + return matched +end + +-- One SlotMachine_CheckForMatches decision at the current stops: +-- "accept" -- pay out `win` +-- "roll" -- a match the flags forbid: roll wheel 3 down one symbol +-- and try again (does NOT consume the reroll counter) +-- "nomatch" -- nothing lined up (the caller consumes +-- wSlotMachineRerollCounter to keep rolling toward a match +-- when the flags allow a win) +function SlotMachine.checkForMatch(wheels, stops, bet, canWin, sevenBar) + local win = SlotMachine.evaluate(wheels, stops, bet) + if not win then return "nomatch" end + if not (canWin or sevenBar) then return "roll", win end + if not sevenBar and (win.symbol == "7" or win.symbol == "BAR") then + return "roll", win + end + return "accept", win +end + +function SlotMachine.new(game, lucky) + local self = setmetatable({}, SlotMachine) + self.game = game + self.wheels = game.data.field.slotWheels + -- intro | bet | spinup | spin | reroll | flash | message | payout | onemore + -- PromptUserToPlaySlots asks "Want to play?" before the session starts. + self.stage = "intro" + self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem + -- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to + -- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem. + self.betIndex = 0 + self.bet = 3 + self.payoutDisplay = 0 -- wPayoutCoins (shown in the top payout box) + self.flash = false + -- wSlotMachineWheelXOffset: 29 matches pokered after LoadSlotMachineTiles + -- draws offset $1c (wheel[15] centred on the bottom row). + self.offset = { 29, 29, 29 } + self.stopping = 0 -- wStoppingWhichSlotMachineWheel + self.slip = { 4, 4 } -- wSlotMachineWheel{1,2}SlipCounter + self.reroll = 4 -- wSlotMachineRerollCounter + self.frame = 0 + self.message = nil + -- the per-visit lucky machine gets better seven-and-bar odds + -- (wSlotMachineSevenAndBarModeChance 250 vs 253) + self.sevenBarChance = lucky and 250 or 253 + self.allowMatchesCounter = 0 -- wSlotMachineAllowMatchesCounter + -- wSlotMachineFlags bits (BIT_SLOTS_CAN_WIN / _WITH_7_OR_BAR) + self.canWin, self.sevenBar = false, false + return self +end + +local function coins(self) return self.game.save.coins or 0 end + +-- SlotMachine_SetFlags, rolled as each spin starts. Seven-and-bar mode, +-- once armed, is sticky (the asm returns early while the bit is set). +function SlotMachine:setFlags() + if self.sevenBar then return end + if self.allowMatchesCounter > 0 then + self.canWin = true + return + end + local r = love.math.random(0, 255) + if r == 0 then + -- 1/256: arm 60 guaranteed-winnable spins. This spin's flags are + -- left untouched (the asm returns before writing them). + self.allowMatchesCounter = 60 + return + end + if r > self.sevenBarChance then + self.sevenBar = true + return + end + if r > 210 then + self.canWin = true + return + end + self.canWin = false +end + +-- SlotMachine_AnimWheel: one half-symbol step; the offset wraps at 30. +function SlotMachine:animWheel(w) + self.offset[w] = (self.offset[w] + 1) % 30 +end + +local function posOf(offset) return (offset + 1) / 2 end + +function SlotMachine:stops() + return { posOf(self.offset[1]), posOf(self.offset[2]), posOf(self.offset[3]) } +end + +-- SlotMachine_StopOrAnimWheel1/2: a stopping wheel may halt only at odd +-- offsets; each centred position spends one slip charge on the wheel's +-- early-stop check, freezing the wheel when the check passes or (at the +-- next centred position) when the charges run out. +function SlotMachine:stopOrAnimWheel(w) + if self.stopping < w then + self:animWheel(w) + return + end + local o = self.offset[w] + if o % 2 == 0 then + self:animWheel(w) + return + end + if self.slip[w] == 0 then return end -- stopped + self.slip[w] = self.slip[w] - 1 + local stop + if w == 1 then + stop = SlotMachine.stopWheel1Early(self.wheels, posOf(o), self.sevenBar) + else + stop = SlotMachine.stopWheel2Early(self.wheels, posOf(self.offset[1]), + posOf(o), self.sevenBar) + end + if stop then + self.slip[w] = 0 + return + end + self:animWheel(w) +end + +-- SlotMachine_StopOrAnimWheel3: no slip charges; stops at the next +-- centred position. Returns true when the spin is over. +function SlotMachine:stopOrAnimWheel3() + if self.stopping < 3 then + self:animWheel(3) + return false + end + if self.offset[3] % 2 == 1 then return true end + self:animWheel(3) + return false +end + +-- SlotMachine_CheckForMatches at the current stops; either resolves the +-- spin or starts a one-symbol wheel-3 roll (stage "reroll"). +function SlotMachine:checkForMatches() + local action, win = SlotMachine.checkForMatch(self.wheels, self:stops(), + self.bet, self.canWin, + self.sevenBar) + if action == "accept" then + self:resolveWin(win) + return + end + if action == "nomatch" then + if not (self.canWin or self.sevenBar) then + self:resolveLose() + return + end + self.reroll = self.reroll - 1 + if self.reroll == 0 then + self:resolveLose() + return + end + end + -- .rollWheel3DownByOneSymbol: two half-steps, one per frame + self.stage = "reroll" + self.rerollSteps = 2 +end + +function SlotMachine:resolveWin(win) + local sym, pay = win.symbol, win.payout + -- SlotReward{300,100,8,15}Func side effects run first (before the flash), + -- and set b = the number of screen flashes. + local flashes + if sym == "7" then + Sound.play(self.game.data, "Get_Item2") + -- SlotReward300Func: "Yeah!", the jackpot always ends an + -- allow-matches streak, and half the time resets the luck flags + if love.math.random(0, 255) >= 128 then + self.canWin, self.sevenBar = false, false + end + self.allowMatchesCounter = 0 + flashes = 20 -- b = $14 + elseif sym == "BAR" then + Sound.play(self.game.data, "Get_Key_Item") + -- SlotReward100Func always clears the luck flags + self.canWin, self.sevenBar = false, false + flashes = 8 -- b = $8 + else + -- SlotReward8Func/SlotReward15Func burn one allow-matches charge + if self.allowMatchesCounter > 0 then + self.allowMatchesCounter = self.allowMatchesCounter - 1 + end + flashes = (pay == 8) and 2 or 4 -- b = $2 (cherry) / $4 (15) + end + self.win = win + self.payoutRemaining = pay + self.payoutDisplay = pay + -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port + -- shows it in the box while the screen flashes. LinedUpText follows. + self.yeah = (sym == "7") + self.message = ("%s lined up!\nScored %d coins!"):format(sym, pay) + -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not + -- credited until the player dismisses the "lined up" text (see startPayout). + self.stage = "flash" + self.flashLeft = flashes + self.flashTimer = 0 + self.flash = false +end + +function SlotMachine:resolveLose() + -- NotThisTimeText, then MainSlotMachineLoop asks "One more go?" + self.message = "Not this time!" + self.stage = "message" + self.afterMessage = "onemore" +end + +-- MainSlotMachineLoop restart: reset the ×3/×2/×1 menu (wCurrentMenuItem 0 +-- defaults the cursor to ×3) and clear the payout box. +function SlotMachine:enterBet() + self.stage = "bet" + self.betIndex = 0 + self.bet = 3 + self.message = nil + self.payoutDisplay = 0 +end + +-- OneMoreGoSlotMachineText + its YES/NO menu. +function SlotMachine:enterOneMore() + self.stage = "onemore" + self.yesno = 1 + self.message = nil + self.payoutDisplay = 0 +end + +-- After a spin resolves: running out of coins ends the session (a 60-frame +-- delay then CloseTextDisplay), otherwise ask "One more go?". +function SlotMachine:afterSpin() + if coins(self) == 0 then + self.message = "Darn!\nRan out of coins!" + self.stage = "message" + self.afterMessage = nil + self.exitTimer = 60 + else + self:enterOneMore() + end +end + +-- SlotMachine_PayCoinsToPlayer: credit one coin every 8 frames (4 for a +-- 7/BAR), a jingle per coin, and flip the object palette every 5 coins. +function SlotMachine:startPayout() + self.stage = "payout" + local sym = self.win and self.win.symbol + self.dripFrames = (sym == "7" or sym == "BAR") and 4 or 8 + self.dripTimer = 0 + self.dripFlash = 5 -- wAnimCounter + self.flash = false +end + +-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?". +function SlotMachine:updateYesNo(onYes) + local input = self.game.input + if input:wasPressed("up") or input:wasPressed("down") then + self.yesno = self.yesno == 1 and 2 or 1 + elseif input:wasPressed("a") then + Sound.play(self.game.data, "Press_AB") + if self.yesno == 1 then onYes() else self.game.stack:pop() end + elseif input:wasPressed("b") then + Sound.play(self.game.data, "Press_AB") + self.game.stack:pop() + end +end + +function SlotMachine:update(dt) + local input = self.game.input + local save = self.game.save + + if self.stage == "intro" then + -- PromptUserToPlaySlots: "A slot machine! Want to play?" + self:updateYesNo(function() self:enterBet() end) + return + end + + if self.stage == "message" then + if self.exitTimer then + -- OutOfCoinsSlotMachineText: DelayFrames 60, then leave + self.exitTimer = self.exitTimer - 1 + if self.exitTimer <= 0 then self.game.stack:pop() end + return + end + if input:wasPressed("a") or input:wasPressed("b") then + Sound.play(self.game.data, "Press_AB") + local after = self.afterMessage + self.afterMessage = nil + if after == "payout" then + self:startPayout() -- WaitForTextScrollButtonPress -> pay + elseif after == "onemore" then + self:afterSpin() + else + self:enterBet() -- NotEnoughCoinsSlotMachineText -> menu + end + end + return + end + + if self.stage == "onemore" then + self:updateYesNo(function() self:enterBet() end) + return + end + + if self.stage == "flash" then + -- .flashScreenLoop: toggle the palette every 5 frames, b times + self.flashTimer = self.flashTimer + 1 + if self.flashTimer >= 5 then + self.flashTimer = 0 + self.flash = self.flash and false or "all" + self.flashLeft = self.flashLeft - 1 + if self.flashLeft <= 0 then + self.flash = false + self.stage = "message" + self.afterMessage = "payout" + end + end + return + end + + if self.stage == "payout" then + if (self.payoutRemaining or 0) <= 0 then + self.flash = false + self.payoutDisplay = 0 + self:afterSpin() + return + end + self.dripTimer = self.dripTimer + 1 + if self.dripTimer >= self.dripFrames then + self.dripTimer = 0 + save.coins = math.min(9999, coins(self) + 1) + self.payoutRemaining = self.payoutRemaining - 1 + self.payoutDisplay = self.payoutRemaining + Sound.play(self.game.data, "Slots_Reward") + self.dripFlash = self.dripFlash - 1 + if self.dripFlash <= 0 then + self.dripFlash = 5 + self.flash = self.flash and false or "reels" -- rOBP0 xor $40 flicker + end + end + return + end + + if self.stage == "bet" then + if input:wasPressed("b") then + self.game.stack:pop() + return + end + -- vertical ×3/×2/×1 menu: UP toward ×3 (betIndex 0), DOWN toward ×1 + if input:wasPressed("up") then self.betIndex = math.max(0, self.betIndex - 1) end + if input:wasPressed("down") then self.betIndex = math.min(2, self.betIndex + 1) end + self.bet = 3 - self.betIndex + if input:wasPressed("a") then + if coins(self) < self.bet then + self.message = "Not enough\ncoins!" + self.afterMessage = "bet" + self.stage = "message" + return + end + save.coins = coins(self) - self.bet + self:setFlags() + self.stopping = 0 + self.slip = { 4, 4 } + self.reroll = 4 + self.frame = 0 + self.spinupSteps = SPINUP_STEPS + self.stage = "spinup" + Sound.play(self.game.data, "Slots_New_Spin") + end + return + end + + if self.stage == "spinup" then + -- SlotMachine_SpinWheels .loop1: 20 free steps before input is read + self.frame = self.frame + 1 + if self.frame % STEP_FRAMES == 0 then + for w = 1, 3 do self:animWheel(w) end + self.spinupSteps = self.spinupSteps - 1 + if self.spinupSteps == 0 then self.stage = "spin" end + end + return + end + + if self.stage == "spin" then + -- SlotMachine_HandleInputWhileWheelsSpin: A stops the next wheel, + -- but is ignored while the previous wheel is still slipping + if input:wasPressed("a") then + local held = (self.stopping == 1 and self.slip[1] > 0) + or (self.stopping == 2 and self.slip[2] > 0) + if not held then + self.stopping = self.stopping + 1 + Sound.play(self.game.data, "Slots_Stop_Wheel") + end + end + self.frame = self.frame + 1 + if self.frame % STEP_FRAMES == 0 then + self:stopOrAnimWheel(1) + self:stopOrAnimWheel(2) + if self:stopOrAnimWheel3() then + self:checkForMatches() + end + end + return + end + + if self.stage == "reroll" then + self:animWheel(3) + self.rerollSteps = self.rerollSteps - 1 + if self.rerollSteps == 0 then + self:checkForMatches() + end + return + end +end + +-- reel symbol screen x (wBaseCoordX $30/$50/$70 minus the OAM 8px offset) +-- and the reel window's vertical clip (rows 4-9 of the machine frame). +local SYM_X = { 40, 72, 104 } +local WIN_TOP, WIN_BOT = 32, 80 + +-- Lazily load the symbol sheet (symbols.png, OAM wheel tiles) and the static +-- machine frame sheet (red_slots_1.png, a tileCols-wide tile atlas). +function SlotMachine:loadArt() + local art = self.game.data.field.slotSymbols + if not art then return nil end + if not self.symbolImg and not self.symbolImgFailed then + local ok, img = pcall(love.graphics.newImage, art.sheet) + if ok then self.symbolImg = img else self.symbolImgFailed = true end + end + if art.tilemap and not self.bgImg and not self.bgImgFailed then + local ok, img = pcall(love.graphics.newImage, art.tilemap.sheet) + if ok then self.bgImg = img else self.bgImgFailed = true end + end + return art +end + +-- The three spinning strips over the reel windows. Each strip scrolls in +-- half-symbol (8px) steps like SlotMachine_AnimWheel; even drawn offsets show +-- three full symbols with wheel[(o+1)/2] on the bottom row. +function SlotMachine:drawReels(art) + for w = 1, 3 do + local x = SYM_X[w] + local wheel = self.wheels[w] + local period = math.max(#wheel - 3, 1) -- 15 real symbols + 3 wrap entries + local d = (self.offset[w] - 1) % 30 -- drawn strip offset + local k = math.floor(d / 2) + for j = k - 1, k + 3 do + local yTop = (WIN_BOT - 16) - 16 * j + 8 * d + local clipTop = math.max(yTop, WIN_TOP) + local clipBot = math.min(yTop + 16, WIN_BOT) + if clipBot > clipTop then + local sym = wheel[(j % period) + 1] + local rect = self.symbolImg and art.symbols[sym] + if rect then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.symbolImg, + love.graphics.newQuad(rect.x, rect.y + (clipTop - yTop), + rect.w, clipBot - clipTop, + self.symbolImg:getDimensions()), + x, clipTop) + elseif yTop >= WIN_TOP and yTop + 16 <= WIN_BOT then + love.graphics.setColor(0, 0, 0, 1) + Font.draw(SHORT[sym] or sym, x, yTop) + end + end + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- The lower dialogue box and, when a prompt is up, the ×3/×2/×1 or YES/NO +-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus). +function SlotMachine:drawBottom() + local lines + if self.stage == "intro" then + lines = { "A slot machine!", "Want to play?" } + elseif self.stage == "bet" then + lines = { "Bet how many", "coins?" } + elseif self.stage == "onemore" then + lines = { "One more", "go?" } + elseif self.stage == "flash" then + lines = self.yeah and { "Yeah!" } or { "Start!" } + elseif self.stage == "spinup" or self.stage == "spin" + or self.stage == "reroll" then + lines = { "Start!" } + elseif self.message then -- message / payout: the wrapped prompt text + lines = {} + for line in (self.message .. "\n"):gmatch("(.-)\n") do + if line ~= "" then lines[#lines + 1] = line end + end + end + if not lines then return end + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(lines[1] or "", 8, 14 * 8) + Font.draw(lines[2] or "", 8, 16 * 8) + if self.stage == "bet" then + Font.drawBox(14, 11, 6, 5) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("×3", 16 * 8, 12 * 8) + Font.draw("×2", 16 * 8, 13 * 8) + Font.draw("×1", 16 * 8, 14 * 8) + Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8) + elseif self.stage == "intro" or self.stage == "onemore" then + -- "One more go?" sits at the right of the box (hlcoord 14,12); the longer + -- "A slot machine!" prompt would clip against it, so the intro's YES/NO + -- floats above the reels instead. + local by = self.stage == "intro" and 6 or 11 + Font.drawBox(13, by, 6, 5) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("YES", 15 * 8, (by + 1) * 8) + Font.draw("NO", 15 * 8, (by + 2) * 8) + Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function SlotMachine:draw() + local art = self:loadArt() + local tm = art and art.tilemap + if not (tm and self.bgImg) then return self:drawPlain(art) end + + -- static machine frame (SlotMachineMap): blit each tile id from the + -- red_slots_1.png tile atlas; below it stays white for the dialogue box + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local iw, ih = self.bgImg:getDimensions() + self.bgQuads = self.bgQuads or {} + for row = 1, tm.rows do + local cells = tm.tiles[row] + for col = 1, tm.cols do + local id = cells[col] + local q = self.bgQuads[id] + if not q then + q = love.graphics.newQuad((id % tm.tileCols) * 8, + math.floor(id / tm.tileCols) * 8, 8, 8, iw, ih) + self.bgQuads[id] = q + end + love.graphics.draw(self.bgImg, q, (col - 1) * 8, (row - 1) * 8) + end + end + + self:drawReels(art) + + -- credit / payout numbers (SlotMachine_PrintCreditCoins @5,1 as BCD, and + -- SlotMachine_PrintPayoutCoins @11,1 with leading zeroes) + love.graphics.setColor(1, 1, 1, 1) + 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) + Font.draw(("%04d"):format(self.payoutDisplay or 0), 88, 8) + love.graphics.setColor(1, 1, 1, 1) + + self:drawBottom() +end + +-- Fallback layout for stale builds without the extracted machine frame. +function SlotMachine:drawPlain(art) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("SLOT MACHINE", 32, 4) + Font.draw(("COINS %4d"):format(coins(self)), 8, 16) + if art then self:drawReels(art) end + love.graphics.setColor(0, 0, 0, 1) + Font.draw(">", 12, 56) + Font.draw("<", 140, 56) + love.graphics.setColor(1, 1, 1, 1) + self:drawBottom() +end + +return SlotMachine diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua new file mode 100644 index 00000000..4e614cb6 --- /dev/null +++ b/src/ui/StartMenu.lua @@ -0,0 +1,117 @@ +-- The START menu (engine/menus/start_menu.asm): entries appear as they +-- become usable -- POKéDEX once Oak gives it, POKéMON once you have any, +-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. + +local Menu = require("src.ui.Menu") + +local StartMenu = {} + +function StartMenu.new(game) + local flags = game.save.flags or {} + local items = {} + + -- POKéDEX: only after Oak hands it over + if flags.EVENT_GOT_POKEDEX then + table.insert(items, { label = "POKéDEX", onSelect = function() + local PokedexMenu = require("src.ui.PokedexMenu") + game.stack:push(PokedexMenu.new(game)) + end }) + end + + -- POKéMON is always listed (draw_start_menu.asm prints it even with + -- an empty party; selecting it then just no-ops) + table.insert(items, { label = "POKéMON", onSelect = function() + if #game.save.party == 0 then return end + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game)) + end }) + + table.insert(items, { label = "ITEM", onSelect = function() + local BagMenu = require("src.ui.BagMenu") + game.stack:push(BagMenu.new(game)) + end }) + + -- the player's name opens the trainer card (StartMenu_TrainerInfo) + table.insert(items, { label = game.save.player.name or "RED", + onSelect = function() + local TrainerCard = require("src.ui.TrainerCard") + game.stack:push(TrainerCard.new(game)) + end }) + + -- SAVE shows the player/badges/dex/time panel then asks to confirm + -- (PrintSaveScreenText) + table.insert(items, { label = "SAVE", onSelect = function() + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local badges = 0 + for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", + "VOLCANOBADGE", "EARTHBADGE" }) do + if game.save.inventory[b] then badges = badges + 1 end + end + local owned = 0 + for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do + owned = owned + 1 + end + local t = math.floor(game.save.playTime or 0) + local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d") + :format(game.save.player.name or "RED", badges, owned, + math.floor(t / 3600), math.floor(t / 60) % 60) + game.stack:push(TextBox.new(game, + panel .. "\fWould you like to\nSAVE the game?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + -- "Now saving..." beat before the write (save.asm + -- NowSavingString), then GameSavedText + SFX_SAVE + game.stack:push(TextBox.new(game, "Now saving...", function() + game:writeSave() + require("src.core.Sound").play(game.data, "Save") + game.stack:push(TextBox.new(game, + (game.save.player.name or "RED") .. " saved\nthe game!")) + end)) + end)) + end)) + end }) + + table.insert(items, { label = "OPTION", onSelect = function() + local OptionsMenu = require("src.ui.OptionsMenu") + game.stack:push(OptionsMenu.new(game)) + end }) + + -- LINK needs a party + if #game.save.party > 0 then + table.insert(items, { label = "LINK", onSelect = function() + local LinkState = require("src.link.LinkState") + game.stack:push(LinkState.new(game)) + end }) + end + + -- the original's EXIT just closed the menu (CloseStartMenu); with a + -- window close button covering that, QUIT instead power-cycles back + -- to the title after a confirm (defaultNo guards accidental quits) + table.insert(items, { label = "QUIT", onSelect = function() + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + game.stack:push(TextBox.new(game, "RETURN TO MAIN\nMENU?", function() + game.stack:push(ChoiceBox.new(game, function(yes) + if yes then game:returnToTitle() end + end, { defaultNo = true })) + end)) + end }) + -- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A + -- (engine/menus/draw_start_menu.asm), so START closes it back to the + -- overworld -- unlike most menus, whose masks omit PAD_START. + local menu = Menu.new(game, items, + { tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true }) + -- the cursor position survives closing the menu + -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) + menu.index = math.min(game.save.startMenuIndex or 1, #items) + local baseUpdate = menu.update + menu.update = function(self, dt) + baseUpdate(self, dt) + game.save.startMenuIndex = self.index + end + return menu +end + +return StartMenu diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua new file mode 100644 index 00000000..c3abc14e --- /dev/null +++ b/src/ui/SummaryMenu.lua @@ -0,0 +1,142 @@ +-- Pokémon status screen, laid out like the original's two pages +-- (engine/pokemon/status_screen.asm): page 1 = pic, No., HP bar, +-- STATUS/, the ATTACK/DEFENSE/SPEED/SPECIAL box and TYPE1/TYPE2/ +-- IDNo/OT; page 2 = EXP and the moves with PP. A flips pages, B (or +-- A on page 2) closes. + +local Font = require("src.render.Font") + +local SummaryMenu = {} +SummaryMenu.__index = SummaryMenu +SummaryMenu.isOpaque = true + +-- SGB: SetPal_StatusScreen -- HP-bar palette overall, mon pic zone in +-- the species palette +function SummaryMenu:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local mon = self.mon + if not mon then return P.wholeNamed(game.data, "MEWMON") end + local bar = P.pal(game.data, P.barPalName(mon.hp, mon.stats.hp)) + if not bar then return nil end + return { P.whole(bar), P.zone(P.monPal(game.data, mon.species), 1, 0, 7, 6) } +end + +function SummaryMenu.new(game, mon) + local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) + local def = game.data.pokemon[mon.species] + if def and def.spriteFront then + local ok, img = pcall(love.graphics.newImage, def.spriteFront) + self.sprite = ok and img or nil + end + require("src.core.Sound").playCry(game.data, mon.species) + return self +end + +function SummaryMenu:update(dt) + local input = self.game.input + -- both A and B advance the pages (WaitForTextScrollButtonPress) + if input:wasPressed("a") or input:wasPressed("b") then + if self.page == 1 then + self.page = 2 + else + self.game.stack:pop() + end + end +end + +-- DrawLineBox (status_screen.asm): a vertical edge down the right, +-- a corner, a horizontal run leftward and the half-arrow ending -- +-- drawn from the same HUD tiles the original loads +local function drawLineBox(tx, ty, b, c) + local HudTiles = require("src.render.HudTiles") + for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end + HudTiles.tile(0x77, tx * 8, (ty + b) * 8) + for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end + HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8) +end + +function SummaryMenu:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local mon = self.mon + local game = self.game + local data = game.data + local def = data.pokemon[mon.species] + + -- shared header: pic (1,0), name (9,1), (14,2), No. (1,7) + if self.sprite then + love.graphics.draw(self.sprite, 8, + math.max(0, 56 - self.sprite:getHeight())) + end + local HudTiles = require("src.render.HudTiles") + love.graphics.setColor(0, 0, 0, 1) + Font.draw(mon.nickname or def.name, 72, 8) + HudTiles.tile(0x6E, 112, 16) -- + Font.draw(tostring(mon.level), 120, 16) + Font.draw(("No.%03d"):format(def.dex or 0), 8, 56) + + if self.page == 1 then + -- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox + -- bracket around the name/HP block + drawLineBox(19, 1, 6, 10) + HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1 + Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) + Font.draw("STATUS/", 72, 48) + Font.draw(mon.status or "OK", 128, 48) + + -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented + Font.drawBox(0, 8, 10, 10) + local stats = { + { "ATTACK", mon.stats.attack }, { "DEFENSE", mon.stats.defense }, + { "SPEED", mon.stats.speed }, { "SPECIAL", mon.stats.special }, + } + for i, s in ipairs(stats) do + local y = 72 + (i - 1) * 16 + Font.draw(s[1], 8, y) + Font.draw(("%3d"):format(s[2]), 48, y + 8) + end + + -- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10) + drawLineBox(19, 9, 8, 6) + Font.draw("TYPE1/", 80, 72) + Font.draw(def.types[1] or "", 88, 80) + if def.types[2] then + Font.draw("TYPE2/", 80, 88) + Font.draw(def.types[2], 88, 96) + end + Font.draw("IDNo/", 80, 104) + -- the trainer ID is rolled at new game (SaveData.newGame) and + -- backfilled on load for old saves + Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112) + Font.draw("OT/", 80, 120) + Font.draw(mon.ot or game.save.player.name or "RED", 96, 128) + else + -- page 2: EXP + the moves with PP (StatusScreen2) + drawLineBox(19, 1, 6, 10) + Font.draw("EXP POINTS", 72, 24) + Font.draw(("%d"):format(mon.exp), 96, 32) + Font.draw("LEVEL UP", 72, 44) + local Growth = require("src.pokemon.Growth") + local nextExp = mon.level < 100 + and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 + Font.draw(("%d to L%d"):format(math.max(0, nextExp), + math.min(100, mon.level + 1)), 88, 52) + Font.drawBox(0, 8, 20, 10) + for i = 1, 4 do + local mv = mon.moves[i] + local y = 72 + (i - 1) * 16 + if mv then + local mdef = data.moves[mv.id] + Font.draw(mdef.name, 16, y) + Font.draw("PP", 88, y + 8) + Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8) + else + Font.draw("-", 16, y) + Font.draw("--", 112, y + 8) + end + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +return SummaryMenu diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua new file mode 100644 index 00000000..9923dbf4 --- /dev/null +++ b/src/ui/TitleState.lua @@ -0,0 +1,223 @@ +-- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm): +-- the logo (or a text fallback while the asset is missing), a cycling +-- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME +-- / OPTION main menu on START or A. + +local Font = require("src.render.Font") +local Music = require("src.core.Music") + +local TitleState = {} +TitleState.__index = TitleState +TitleState.isOpaque = true + +-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, +-- the version-ribbon band LOGO1, the rest MEWMON. +function TitleState:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local z = { + P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), + P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9), + P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), + } + return z[3] and z or nil +end + +-- the Red-version TitleMons list (data/pokemon/title_mons.asm): +-- TitleScreenPickNewMon draws a random, never-repeating pick from it +local CYCLE_SPECIES = { + "CHARMANDER", "SQUIRTLE", "BULBASAUR", "WEEDLE", "NIDORAN_M", "SCYTHER", + "PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO", + "PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP", +} +local CYCLE_FRAMES = 240 -- the original waits ~4s between picks + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +function TitleState.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TitleState) + self.game = game + self.onNewGame = opts.onNewGame + self.onContinue = opts.onContinue + self.logo = tryImage("assets/logo/pokemon_logo.png") + self.version = tryImage("assets/generated/title/red_version.png") + self.player = tryImage("assets/generated/title/player.png") + self.sprites = {} -- species -> image or false (load failed) + self.cycleIndex = 1 + self.timer = 0 + self.blink = 0 + return self +end + +function TitleState:enter() + local data = self.game.data + if data.audio and data.audio.songs and data.audio.songs.Music_TitleScreen then + pcall(Music.play, data, "Music_TitleScreen") + end +end + +function TitleState:currentSprite() + local species = CYCLE_SPECIES[self.cycleIndex] + local cached = self.sprites[species] + if cached == nil then + local def = self.game.data.pokemon[species] + cached = tryImage(def and def.spriteFront) or false + self.sprites[species] = cached + end + return cached or nil +end + +local function hasSave() + local ok, info = pcall(function() + return love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo("save.lua") or nil + end) + return ok and info ~= nil +end + +-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo): +-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing +-- CONTINUE. A confirms and loads the game, B returns to the main menu. +local ContinueInfo = {} +ContinueInfo.__index = ContinueInfo + +function ContinueInfo.new(title, save) + return setmetatable({ title = title, game = title.game, save = save }, + ContinueInfo) +end + +function ContinueInfo:update(dt) + local input = self.game.input + if input:wasPressed("a") then + self.game.stack:pop() + if self.title.onContinue then self.title.onContinue() end + elseif input:wasPressed("b") then + self.game.stack:pop() + self.title:openMenu() + end +end + +function ContinueInfo:draw() + local save = self.save + -- box at (4,7), 8x14 content; labels double-spaced from (5,9) + Font.drawBox(4, 7, 16, 10) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("PLAYER", 40, 72) + Font.draw((save.player and save.player.name) or "RED", 96, 72) + local badges = 0 + for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", + "VOLCANOBADGE", "EARTHBADGE" }) do + if save.inventory and save.inventory[b] then badges = badges + 1 end + end + Font.draw("BADGES", 40, 88) + Font.draw(("%2d"):format(badges), 128, 88) + local owned = 0 + for _ in pairs(save.pokedex and save.pokedex.owned or {}) do + owned = owned + 1 + end + Font.draw("POKéDEX", 40, 104) + Font.draw(("%3d"):format(owned), 120, 104) + local t = math.floor(save.playTime or 0) + Font.draw("TIME", 40, 120) + Font.draw(("%3d:%02d"):format(math.floor(t / 3600), + math.floor(t / 60) % 60), 104, 120) + love.graphics.setColor(1, 1, 1, 1) +end + +function TitleState:openMenu() + local Menu = require("src.ui.Menu") + local game = self.game + local items = {} + if hasSave() then + table.insert(items, { label = "CONTINUE", onSelect = function() + -- peek at the save for the info window; fall through if the + -- file can't be read + local ok, loaded = pcall(require("src.core.SaveData").load) + if ok and loaded then + game.stack:push(ContinueInfo.new(self, loaded)) + elseif self.onContinue then + self.onContinue() + end + end }) + end + table.insert(items, { label = "NEW GAME", onSelect = function() + if self.onNewGame then self.onNewGame() end + end }) + table.insert(items, { label = "OPTION", onSelect = function() + game.stack:push(require("src.ui.OptionsMenu").new(game)) + end }) + game.stack:push(Menu.new(game, items, + { tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 })) +end + +function TitleState:update(dt) + self.timer = self.timer + 1 + self.blink = (self.blink + 1) % 60 + if self.timer >= CYCLE_FRAMES then + self.timer = 0 + -- random pick that never repeats the current one + local pick = self.cycleIndex + while pick == self.cycleIndex do + pick = love.math.random(1, #CYCLE_SPECIES) + end + self.cycleIndex = pick + self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in + end + if self.slideIn and self.slideIn > 0 then + self.slideIn = self.slideIn - 1 + end + local input = self.game.input + if input:wasPressed("start") or input:wasPressed("a") then + -- the title mon cries when you leave the title (.finishedWaiting) + require("src.core.Sound").playCry(self.game.data, + CYCLE_SPECIES[self.cycleIndex]) + self:openMenu() + end +end + +-- The original tilemap (engine/movie/title.asm): logo at tile (2,1), +-- the version ribbon at (7,8), Red's title art as OAM at px (82,80), +-- the title mon in the 7x7 box at tile (5,10), copyright on row 17. +function TitleState:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + if self.logo then + love.graphics.draw(self.logo, 16, 8) + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24) + love.graphics.setColor(1, 1, 1, 1) + end + if self.version then + -- the strip holds Red+Green+Version glyphs; the tilemap prints + -- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version") + local iw, ih = self.version:getDimensions() + love.graphics.draw(self.version, + love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64) + love.graphics.draw(self.version, + love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) + end + local sprite = self:currentSprite() + if sprite then + local w, h = sprite:getDimensions() + local slide = (self.slideIn or 0) * 8 -- scroll in from the right + -- bottom-aligned and centered in the (5,10)-(11,16) tile box + love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide, + 136 - h) + end + -- Red is OAM in the original: he draws over the mon's box edge + if self.player then + love.graphics.draw(self.player, 82, 80) + end + love.graphics.setColor(0, 0, 0, 1) + -- the copyright row (tile 2,17); + Font.draw("2026 bois club games", 1, 136) + love.graphics.setColor(1, 1, 1, 1) +end + +return TitleState diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua new file mode 100644 index 00000000..30e0b9a4 --- /dev/null +++ b/src/ui/TownMap.lua @@ -0,0 +1,330 @@ +-- TOWN MAP viewer (engine/menus/town_map.asm; location data from +-- data/maps/town_map_entries.asm via the extractor's field.townMap). +-- +-- Grid mode (when field.townMap provides coordinates): the 20x18-tile +-- Kanto map with a filled square per known location -- routes lighter, +-- towns darker -- a blinking cursor the d-pad snaps between locations, +-- the selected name in a banner up top, and the player's current +-- location blinking. List mode (townMap data missing): up/down through +-- an ordered list of fly towns instead. B closes. + +local Font = require("src.render.Font") +local Sound = require("src.core.Sound") + +local TownMap = {} +TownMap.__index = TownMap +TownMap.isOpaque = true + +-- SGB: PalPacket_TownMap, whole screen +function TownMap:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "TOWNMAP") +end + +-- pull x/y out of a townMap entry regardless of the exact shape the +-- extractor settled on ({x=,y=}, {col=,row=} or {coords={x=,y=}}) +local function entryCoords(e) + if type(e) ~= "table" then return nil end + local c = e.coords or e + local x = tonumber(c.x or c.col) + local y = tonumber(c.y or c.row) + return x, y +end + +local function entryName(e, mapId) + local name = type(e) == "table" and (e.name or e.label) or nil + return name or mapId:gsub("_", " ") +end + +local function isRoute(loc) + return loc.name:find("ROUTE", 1, true) ~= nil +end + +-- Build the ordered location list. Grid mode dedupes shared entries +-- (interior maps point at their town's square); list mode falls back to +-- the fly towns so the screen still works without townMap data. +local function buildLocations(game) + local field = game.data.field or {} + local townMap = field.townMap + -- the extractor nests the per-map entries under .locations + if type(townMap) == "table" and type(townMap.locations) == "table" then + townMap = townMap.locations + end + local locs, byMap = {}, {} + if type(townMap) == "table" and next(townMap) then + local seen = {} + for mapId, e in pairs(townMap) do + local x, y = entryCoords(e) + if x and y then + local name = entryName(e, mapId) + local key = ("%s:%d:%d"):format(name, x, y) + local loc = seen[key] + if not loc then + loc = { name = name, x = x, y = y } + seen[key] = loc + table.insert(locs, loc) + end + byMap[mapId] = loc + end + end + if #locs > 0 then + table.sort(locs, function(a, b) + if a.y ~= b.y then return a.y < b.y end + if a.x ~= b.x then return a.x < b.x end + return a.name < b.name + end) + return locs, byMap, "grid" + end + end + -- fallback: towns from the fly order (deduped, outdoor maps only) + local seen = {} + for _, mapId in ipairs(field.flyOrder or {}) do + local def = game.data.maps and game.data.maps[mapId] + if not seen[mapId] and def and def.tileset == "OVERWORLD" then + seen[mapId] = true + local loc = { name = mapId:gsub("_", " ") } + table.insert(locs, loc) + byMap[mapId] = loc + end + end + if #locs == 0 then locs = { { name = "KANTO" } } end + return locs, byMap, "list" +end + +-- load the extracted Kanto background (nil on stale asset builds) +local function loadBackground(game) + local tm = (game.data.field or {}).townMap or {} + local bg = tm.background + if not (bg and bg.map and bg.tiles) then return nil end + local ok, img = pcall(love.graphics.newImage, bg.tiles.path) + if not ok then return nil end + local quads = {} + local iw, ih = img:getDimensions() + local per = iw / 8 + for i = 0, per * (ih / 8) - 1 do + quads[i] = love.graphics.newQuad((i % per) * 8, + math.floor(i / per) * 8, 8, 8, iw, ih) + end + local cursor + if bg.cursor then + local okc, c = pcall(love.graphics.newImage, bg.cursor.path) + cursor = okc and c or nil + end + return { img = img, quads = quads, map = bg.map, cursor = cursor } +end + +-- town-map grid -> screen pixels (TownMapCoordsToOAMCoords: the 16x16 +-- nybble grid sits 2 tiles in and 1 tile down on the 20x18 screen) +local function markerXY(loc) + return loc.x * 8 + 16, loc.y * 8 + 8 +end + +-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) -- +-- blink a nest icon on every map whose wild slots hold the species +function TownMap.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TownMap) + self.game = game + self.bg = loadBackground(game) + self.locs, self.byMap, self.mode = buildLocations(game) + if opts.nestSpecies then + self.nestSpecies = opts.nestSpecies + self.nests = {} + local seen = {} + for mapId, enc in pairs(game.data.encounters or {}) do + local found = false + for _, group in pairs(enc) do + for _, slot in ipairs(group.slots or {}) do + if slot.species == opts.nestSpecies then found = true break end + end + if found then break end + end + local loc = found and self.byMap[mapId] + if loc and not seen[loc] then + seen[loc] = true + table.insert(self.nests, loc) + end + end + local ok, img = pcall(love.graphics.newImage, + "assets/generated/townmap/nest.png") + self.nestIcon = ok and img or nil + end + -- the player's current location (guard: overworld may not be running) + local mapId = game.overworld and game.overworld.map and game.overworld.map.id + self.playerLoc = mapId and self.byMap[mapId] or nil + self.sel = 1 + for i, loc in ipairs(self.locs) do + if loc == self.playerLoc then self.sel = i break end + end + self.blink = 0 + return self +end + +-- snap the cursor to the nearest location in the pressed direction +function TownMap:moveGrid(dx, dy) + local cur = self.locs[self.sel] + local best, bestScore + for i, loc in ipairs(self.locs) do + if i ~= self.sel then + local ddx, ddy = loc.x - cur.x, loc.y - cur.y + local fwd = ddx * dx + ddy * dy -- progress along the d-pad axis + local side = math.abs(ddx * dy) + math.abs(ddy * dx) + if fwd > 0 then + local score = fwd + side * 3 -- prefer staying on-axis + if not best or score < bestScore then best, bestScore = i, score end + end + end + end + if best then + self.sel = best + Sound.play(self.game.data, "Tink") + end +end + +function TownMap:moveList(step) + local n = #self.locs + if n < 2 then return end + self.sel = (self.sel - 1 + step) % n + 1 + Sound.play(self.game.data, "Tink") +end + +function TownMap:update(dt) + self.blink = (self.blink + 1) % 32 + local input = self.game.input + if input:wasPressed("b") then + Sound.play(self.game.data, "Press_AB") + self.game.stack:pop() + return + end + if self.nestSpecies then + if input:wasPressed("a") then + Sound.play(self.game.data, "Press_AB") + self.game.stack:pop() + end + elseif self.mode == "grid" then + if input:wasPressed("up") then self:moveGrid(0, -1) + elseif input:wasPressed("down") then self:moveGrid(0, 1) + elseif input:wasPressed("left") then self:moveGrid(-1, 0) + elseif input:wasPressed("right") then self:moveGrid(1, 0) + end + else + if input:wasPressed("up") then self:moveList(-1) + elseif input:wasPressed("down") then self:moveList(1) + end + end +end + +local function drawSquare(loc) + if isRoute(loc) then + love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter + else + love.graphics.setColor(0.25, 0.25, 0.25, 1) -- towns darker + end + love.graphics.rectangle("fill", loc.x * 8 + 1, loc.y * 8 + 1, 6, 6) +end + +function TownMap:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + + local selected = self.locs[self.sel] + if self.mode == "grid" and self.bg then + -- the real Kanto map (LoadTownMap's RLE tilemap) + for i, t in ipairs(self.bg.map) do + local col, row = (i - 1) % 20, math.floor((i - 1) / 20) + love.graphics.draw(self.bg.img, self.bg.quads[t], col * 8, row * 8) + end + if self.nestSpecies then + -- AREA mode: blinking nests, the species name up top + if self.blink % 16 < 10 then + for _, loc in ipairs(self.nests) do + local x, y = markerXY(loc) + if self.nestIcon then + love.graphics.draw(self.nestIcon, x, y) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + end + end + love.graphics.rectangle("fill", 0, 0, 160, 8) + love.graphics.setColor(0, 0, 0, 1) + local def = self.game.data.pokemon[self.nestSpecies] + local name = def and def.name or self.nestSpecies + Font.draw(#self.nests > 0 and (name .. "'s NEST") + or (name .. " AREA UNKNOWN"), 8, 0) + love.graphics.setColor(1, 1, 1, 1) + return + end + -- the player's current location blinks (slow phase) + if self.playerLoc and self.blink < 20 then + local x, y = markerXY(self.playerLoc) + love.graphics.setColor(0.75, 0.1, 0.1, 1) + love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + -- blinking cursor on the selected location + if selected and self.blink % 16 < 10 then + local x, y = markerXY(selected) + if self.bg.cursor then + love.graphics.draw(self.bg.cursor, x, y) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7) + love.graphics.setColor(1, 1, 1, 1) + end + end + -- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name) + love.graphics.rectangle("fill", 0, 0, 160, 8) + love.graphics.setColor(0, 0, 0, 1) + if selected then Font.draw(selected.name, 8, 0) end + love.graphics.setColor(1, 1, 1, 1) + return + end + + love.graphics.setColor(0, 0, 0, 1) + Font.drawBox(0, 0, 20, 18) + if self.mode == "grid" then + -- stale assets (no background art): the old abstract squares + for _, loc in ipairs(self.locs) do + drawSquare(loc) + end + if self.playerLoc and self.blink < 20 then + love.graphics.setColor(0.75, 0.1, 0.1, 1) + love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, + self.playerLoc.y * 8 + 2, 4, 4) + end + if selected and self.blink % 16 < 10 then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", selected.x * 8 + 0.5, + selected.y * 8 + 0.5, 7, 7) + end + else + -- list fallback: show a window of names, cursor on the selection + love.graphics.setColor(0, 0, 0, 1) + local rows = 6 + local first = math.max(1, math.min(self.sel - 2, #self.locs - rows + 1)) + for i = 0, rows - 1 do + local loc = self.locs[first + i] + if loc then + local y = 40 + i * 16 + if first + i == self.sel and self.blink % 16 < 10 then + Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph + end + Font.draw(loc.name, 24, y) + if loc == self.playerLoc and self.blink < 20 then + -- blinking marker on the player's current town + love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4) + end + end + end + end + + -- name banner across the top + Font.drawBox(0, 0, 20, 3) + love.graphics.setColor(0, 0, 0, 1) + if selected then Font.draw(selected.name, 8, 8) end + love.graphics.setColor(1, 1, 1, 1) +end + +return TownMap diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua new file mode 100644 index 00000000..137854ee --- /dev/null +++ b/src/ui/TradeAnim.lua @@ -0,0 +1,102 @@ +-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm): +-- the traded POKéMON rises away with its cry and a goodbye, then the +-- received one descends with its cry and "take good care" text. +-- A skips the slide animations ahead. Calls onDone() after popping. + +local Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") + +local TradeAnim = {} +TradeAnim.__index = TradeAnim +TradeAnim.isOpaque = true + +-- SGB: generic whole-screen palette (SET_PAL_GENERIC) +function TradeAnim:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +local SLIDE_FRAMES = 90 +local REST_Y = 44 -- resting top of the sprite, roughly screen centre + +local function tryImage(path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +local function nameOf(game, mon) + local def = game.data.pokemon[mon.species] + return mon.nickname or (def and def.name) or mon.species +end + +local function spriteOf(game, mon) + local def = game.data.pokemon[mon.species] + return tryImage(def and def.spriteFront) +end + +function TradeAnim.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TradeAnim) + self.game = game + self.sent = opts.sent + self.received = opts.received + self.onDone = opts.onDone + self.sentSprite = spriteOf(game, self.sent) + self.receivedSprite = spriteOf(game, self.received) + self.phase = "out" + self.t = 0 + return self +end + +function TradeAnim:enter() + Sound.playCry(self.game.data, self.sent.species) +end + +function TradeAnim:update(dt) + local input = self.game.input + if self.phase == "out" or self.phase == "in" then + self.t = self.t + 1 + if input:wasPressed("a") then self.t = SLIDE_FRAMES end + if self.t < SLIDE_FRAMES then return end + if self.phase == "out" then + self.phase = "goodbye" + self.game.stack:push(TextBox.new(self.game, + ("Goodbye %s!"):format(nameOf(self.game, self.sent)), + function() + self.phase = "in" + self.t = 0 + Sound.playCry(self.game.data, self.received.species) + end)) + else + self.phase = "takecare" + self.game.stack:push(TextBox.new(self.game, + ("Take good care\nof %s!"):format(nameOf(self.game, self.received)), + function() + self.game.stack:pop() + if self.onDone then self.onDone() end + end)) + end + end +end + +function TradeAnim:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local sprite, y + if self.phase == "out" then + -- the sent mon rises up and off the screen + sprite = self.sentSprite + y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60)) + elseif self.phase == "in" or self.phase == "takecare" then + -- the received mon descends into place + sprite = self.receivedSprite + local t = self.phase == "in" and self.t or SLIDE_FRAMES + y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60)) + end + if sprite and y then + local w = sprite:getWidth() + love.graphics.draw(sprite, math.floor((160 - w) / 2), y) + end +end + +return TradeAnim diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua new file mode 100644 index 00000000..0bbd4bda --- /dev/null +++ b/src/ui/TrainerCard.lua @@ -0,0 +1,151 @@ +-- Trainer card (engine/menus/start_sub_menus.asm DrawTrainerInfo): +-- NAME / MONEY / TIME with the player's front pic upper-right, the +-- circle-dotted BADGES banner, and the numbered badge grid. The boxes +-- are built from the real trainer_info.png frame tiles (the patterned +-- band + line style). + +local Font = require("src.render.Font") + +local TrainerCard = {} +TrainerCard.__index = TrainerCard +TrainerCard.isOpaque = true + +-- SGB: PalPacket_TrainerCard leads with MEWMON +function TrainerCard:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + +-- gym order (data/scripts/victories.lua badge order) +local BADGES = { + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", +} + +local function tryImage(path) + local ok, img = pcall(love.graphics.newImage, path) + return ok and img or nil +end + +local function quads16(img, count, stride, x0, y0) + local q = {} + local iw, ih = img:getDimensions() + for i = 0, count - 1 do + q[i] = love.graphics.newQuad(x0 or 0, (y0 or 0) + i * stride, 16, 16, iw, ih) + end + return q +end + +function TrainerCard.new(game) + local self = setmetatable({ game = game }, TrainerCard) + local img = tryImage("assets/generated/trainer_card/badges.png") + if img then + -- 8 pairs of [gym leader face, badge] + self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) } + end + local nums = tryImage("assets/generated/trainer_card/badge_numbers.png") + if nums then + self.nums = { img = nums, quads = {} } + local iw, ih = nums:getDimensions() + for i = 0, 7 do + self.nums.quads[i] = love.graphics.newQuad((i % 2) * 8, + math.floor(i / 2) * 8, + 8, 8, iw, ih) + end + end + -- frame tiles (3x3 sheet): 0 bottom, 1 right, 2 tl, 3 top, 4 tr, + -- 5 left, 6 bl, 7 br, 8 solid pattern + local frame = tryImage("assets/generated/trainer_card/trainer_info.png") + if frame then + self.frame = { img = frame, quads = {} } + for i = 0, 8 do + self.frame.quads[i] = love.graphics.newQuad((i % 3) * 8, + math.floor(i / 3) * 8, + 8, 8, frame:getDimensions()) + end + end + self.circle = tryImage("assets/generated/trainer_card/circle_tile.png") + self.pic = tryImage("assets/generated/trainer_card/red.png") + return self +end + +function TrainerCard:update(dt) + local input = self.game.input + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + end +end + +-- a frame box in tile coords from the trainer_info tiles +function TrainerCard:frameBox(tx, ty, tw, th) + if not self.frame then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5, + tw * 8 - 1, th * 8 - 1) + love.graphics.setColor(1, 1, 1, 1) + return + end + local img, q = self.frame.img, self.frame.quads + love.graphics.setColor(1, 1, 1, 1) + local x1, y1 = (tx + tw - 1) * 8, (ty + th - 1) * 8 + love.graphics.draw(img, q[2], tx * 8, ty * 8) + love.graphics.draw(img, q[4], x1, ty * 8) + love.graphics.draw(img, q[6], tx * 8, y1) + love.graphics.draw(img, q[7], x1, y1) + for i = 1, tw - 2 do + love.graphics.draw(img, q[3], (tx + i) * 8, ty * 8) + love.graphics.draw(img, q[0], (tx + i) * 8, y1) + end + for j = 1, th - 2 do + love.graphics.draw(img, q[5], tx * 8, (ty + j) * 8) + love.graphics.draw(img, q[1], x1, (ty + j) * 8) + end +end + +function TrainerCard:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local save = self.game.save + + -- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right + self:frameBox(0, 0, 20, 8) + if self.pic then + love.graphics.draw(self.pic, 104, 4) + end + love.graphics.setColor(0, 0, 0, 1) + Font.draw("NAME/" .. (save.player.name or "RED"), 16, 16) + Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32) + local t = math.floor(save.playTime or 0) + Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600), + math.floor(t / 60) % 60), 16, 48) + + -- the circle-dotted BADGES banner (TrainerInfo_BadgesText) + self:frameBox(0, 8, 20, 3) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("BADGES", 56, 72) + if self.circle then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.circle, 48, 72) + love.graphics.draw(self.circle, 104, 72) + love.graphics.setColor(0, 0, 0, 1) + end + + -- numbered badge grid (rows 11-17): earned solid, unearned dimmed + self:frameBox(0, 11, 20, 7) + for i = 1, 8 do + local col, row = (i - 1) % 4, math.floor((i - 1) / 4) + local tx, ty = 16 + col * 32, 94 + row * 24 + if self.nums then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) + end + if self.badges and save.inventory[BADGES[i]] then + -- unearned badge slots stay blank (DrawBadges) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.badges.img, self.badges.quads[i - 1], + tx + 4, ty + 6) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +return TrainerCard diff --git a/src/world/Collision.lua b/src/world/Collision.lua new file mode 100644 index 00000000..74331328 --- /dev/null +++ b/src/world/Collision.lua @@ -0,0 +1,75 @@ +-- Movement permission checks: tile passability (from generated collision +-- data), map bounds, and entity occupancy. + +local Collision = {} + +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +Collision.DELTA = DELTA + +function Collision.target(cx, cy, dir) + local d = DELTA[dir] + return cx + d[1], cy + d[2] +end + +-- entities: array of anything with cellX/cellY (and optional targetX/targetY +-- while mid-step, so nobody walks into a cell being entered). +function Collision.occupied(entities, cx, cy, ignore) + for _, e in ipairs(entities) do + if e ~= ignore then + if (e.cellX == cx and e.cellY == cy) or + (e.targetX == cx and e.targetY == cy) then + return e + end + end + end + return nil +end + +-- Tile-pair (elevation) collisions: certain tile pairs can't be crossed +-- in a given tileset (cave/forest ledges). data set via Collision.load. +local tilePairs = nil + +function Collision.load(data) + tilePairs = data.field and data.field.tilePairs or { land = {}, water = {} } +end + +local function pairBlocked(map, mover, sx, sy, tx, ty) + if not tilePairs then return false end + local list = mover.surfing and tilePairs.water or tilePairs.land + if not list or #list == 0 then return false end + local tileset = map.def.tileset + local a = map:cellTile(sx, sy) + local b = map:cellTile(tx, ty) + for _, p in ipairs(list) do + if p.tileset == tileset + and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then + return true + end + end + return false +end + +-- Returns true when the mover may step from (cx,cy) toward dir. +-- Out-of-bounds is blocked here; the OverworldController handles map +-- connections and edge warps before asking. +function Collision.canMove(map, entities, mover, dir) + local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) + if not map:inBounds(tx, ty) then + return false, "bounds" + end + if not map:isWalkableCell(tx, ty) then + -- surfers may ride water cells + if not (mover.surfing and map:isWaterCell(tx, ty)) then + return false, "tile" + end + end + if pairBlocked(map, mover, mover.cellX, mover.cellY, tx, ty) then + return false, "tile" + end + if Collision.occupied(entities, tx, ty, mover) then + return false, "entity" + end + return true +end + +return Collision diff --git a/src/world/ElevatorShake.lua b/src/world/ElevatorShake.lua new file mode 100644 index 00000000..ae153ad3 --- /dev/null +++ b/src/world/ElevatorShake.lua @@ -0,0 +1,95 @@ +-- ShakeElevator (pokered engine/overworld/elevator.asm), run after +-- DisplayElevatorFloorMenu picks a floor (the elevator map script fires +-- it via BIT_CUR_MAP_USED_ELEVATOR): +-- +-- * lead-in: ShakeElevator's two ShakeElevatorRedrawRow calls (each +-- ends in Delay3) plus its own Delay3 are 9 frames; the SilphCo / +-- RocketHideout ...ShakeScripts prefix one more Delay3 (12 total) +-- while Celadon's farjps straight in (9). The row redraws +-- themselves are a VRAM patch with no port equivalent -- only their +-- delays are kept. +-- * SFX_STOP_ALL_MUSIC: the map theme cuts out for the ride. +-- * 100 loop iterations, 2 frames each (`ld b, 100` / `ld c, 2` + +-- DelayFrames): `e ^= $fe` flips e between $01 and $ff, so +-- hSCY = rest + e alternates -1 / +1 around the resting scroll +-- (first offset -1), and SFX_COLLISION plays every iteration. +-- * hSCY restored, SFX_STOP_ALL_MUSIC again, then SFX_SAFARI_ZONE_PA +-- plays and .musicLoop busy-waits on wChannelSoundIDs+CHAN5 until +-- it ends. +-- * UpdateSprites + PlayDefaultMusic: the map theme restarts. +-- +-- SCY scrolls the BG layer only -- OAM sprites stay put -- so this +-- state drives ow.bgShakeY, which OverworldState:drawWorld adds to the +-- tile layers and not to the sprites. While it sits on the stack the +-- overworld below neither updates nor takes input, like the original's +-- blocking loop. + +local Sound = require("src.core.Sound") + +local ElevatorShake = {} +ElevatorShake.__index = ElevatorShake + +local CYCLES = 100 -- ld b, 100 +local FRAMES_PER_CYCLE = 2 -- ld c, 2 / call DelayFrames + +-- opts.preFrames: lead-in delay frames (12 Silph/Rocket, 9 Celadon); +-- opts.onDone: called once the ride is over (the floor warp) +function ElevatorShake.new(game, ow, opts) + opts = opts or {} + return setmetatable({ + game = game, + ow = ow, + preFrames = opts.preFrames or 12, + onDone = opts.onDone, + phase = "pre", + frames = 0, + offset = 1, -- ld e, $1; the first `xor $fe` flips it to -1 + }, ElevatorShake) +end + +function ElevatorShake:update() + if self.phase == "pre" then + if self.frames < self.preFrames then + self.frames = self.frames + 1 + return + end + -- SFX_STOP_ALL_MUSIC: the theme stops just before the first scroll + -- write, in the same frame slice + require("src.core.Music").stop() + self.phase = "shake" + self.frames = 0 + end + if self.phase == "shake" then + if self.frames % FRAMES_PER_CYCLE == 0 then + -- one .shakeLoop iteration: flip the offset, write the scroll, + -- retrigger SFX_COLLISION + self.offset = -self.offset + self.ow.bgShakeY = self.offset + Sound.play(self.game.data, "Collision") + end + self.frames = self.frames + 1 + if self.frames >= CYCLES * FRAMES_PER_CYCLE then + -- ld a, d / ldh [hSCY], a: back to the resting scroll, then the + -- arrival chime + self.ow.bgShakeY = 0 + if Sound.stop then Sound.stop("Collision") end -- SFX_STOP_ALL_MUSIC + Sound.play(self.game.data, "Safari_Zone_PA") + self.phase = "pa" + end + return + end + -- .musicLoop: hold until SFX_SAFARI_ZONE_PA finishes (headless the + -- sound never starts, so this resolves on the next frame) + if Sound.isPlaying and Sound.isPlaying("Safari_Zone_PA") then return end + require("src.core.Music").restoreMap(self.game.data) -- PlayDefaultMusic + self.game.stack:pop() + if self.onDone then self.onDone() end +end + +-- safety: never leave a scroll offset behind if popped early +-- (e.g. Game:returnToTitle popping the whole stack) +function ElevatorShake:exit() + if self.ow then self.ow.bgShakeY = 0 end +end + +return ElevatorShake diff --git a/src/world/Encounter.lua b/src/world/Encounter.lua new file mode 100644 index 00000000..a9666601 --- /dev/null +++ b/src/world/Encounter.lua @@ -0,0 +1,30 @@ +-- Wild encounters from generated encounter tables. +-- Gen 1: on each step into a grass/water cell, a battle starts when +-- rand(0..255) < map encounter rate; the slot is picked with the original +-- probability buckets. + +local Encounter = {} + +-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm) +local SLOT_BUCKETS = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 } + +function Encounter.roll(encounterDef, rng) + rng = rng or love.math.random + if not encounterDef then return nil end + local grass = encounterDef.grass + if not grass or grass.rate == 0 then return nil end + if rng(0, 255) >= grass.rate then return nil end + local pick = rng(0, 255) + for i, threshold in ipairs(SLOT_BUCKETS) do + if pick < threshold then + local slot = grass.slots[i] + if slot then + return { species = slot.species, level = slot.level } + end + return nil + end + end + return nil +end + +return Encounter diff --git a/src/world/Map.lua b/src/world/Map.lua new file mode 100644 index 00000000..13a98a4d --- /dev/null +++ b/src/world/Map.lua @@ -0,0 +1,119 @@ +-- Runtime map built from generated data. All queries use "cells": the +-- 16x16 walk grid (2x2 tiles). A map is width x height blocks; each block +-- is 2x2 cells (4x4 tiles). +-- +-- Collision follows the original engine: a cell is passable when the +-- BOTTOM-LEFT 8x8 tile of the cell is in the tileset's walkable list +-- (pokered checks the tile at the sprite's feet). Doors, warp tiles and +-- grass use the same convention. + +local Map = {} +Map.__index = Map + +function Map.new(def, tilesetDef) + local self = setmetatable({}, Map) + self.def = def + self.tileset = tilesetDef + self.id = def.id + self.widthCells = def.width * 2 + self.heightCells = def.height * 2 + + self.walkable = {} + for _, t in ipairs(tilesetDef.walkable) do self.walkable[t] = true end + self.doorTiles = {} + for _, t in ipairs(tilesetDef.doorTiles or {}) do self.doorTiles[t] = true end + self.warpTiles = {} + for _, t in ipairs(tilesetDef.warpTiles or {}) do self.warpTiles[t] = true end + + self.warpAt = {} + for i, w in ipairs(def.warps) do + self.warpAt[w.y * self.widthCells + w.x] = { index = i, def = w } + end + self.signAt = {} + for _, s in ipairs(def.signs) do + self.signAt[s.y * self.widthCells + s.x] = s + end + return self +end + +function Map:blockAt(bx, by) + if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then + return self.def.borderBlock + end + return self.def.blocks[by * self.def.width + bx + 1] +end + +-- tile id at tile coordinates (8px grid), border-extended +function Map:tileAt(tx, ty) + local bx, by = math.floor(tx / 4), math.floor(ty / 4) + local block = self.tileset.blocks[self:blockAt(bx, by) + 1] + local ix = (ty % 4) * 4 + (tx % 4) + 1 + return block[ix] +end + +-- the collision tile of a cell: bottom-left 8x8 tile +function Map:cellTile(cx, cy) + return self:tileAt(cx * 2, cy * 2 + 1) +end + +function Map:inBounds(cx, cy) + return cx >= 0 and cy >= 0 and cx < self.widthCells and cy < self.heightCells +end + +function Map:isWalkableCell(cx, cy) + return self.walkable[self:cellTile(cx, cy)] or false +end + +function Map:isGrassCell(cx, cy) + local grass = self.tileset.grassTile + return grass ~= nil and self:cellTile(cx, cy) == grass +end + +-- Water and eastern-shore tiles (item_effects.asm IsNextTileShoreOrWater, +-- home/overworld.asm CollisionCheckOnWater): $14 everywhere; the shore +-- tiles $32 and $48 (Safari Zone) everywhere EXCEPT the SHIP_PORT +-- tileset, where $32 is the dock's boarding platform (a land tile). +-- Tileset membership in water_tilesets.asm is checked by the caller. +function Map:isWaterCell(cx, cy) + local t = self:cellTile(cx, cy) + if t == 0x14 then return true end + if self.def.tileset == "SHIP_PORT" then return false end + return t == 0x32 or t == 0x48 +end + +-- Replace a block (Cut trees); the caller rebuilds the renderer. +function Map:setBlock(bx, by, block) + if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then + return + end + self.def.blocks[by * self.def.width + bx + 1] = block +end + +-- true if the cell's collision tile is a door or warp-activating tile +function Map:isWarpTileCell(cx, cy) + local t = self:cellTile(cx, cy) + return self.doorTiles[t] or self.warpTiles[t] or false +end + +-- counter tiles allow talking to NPCs across them (mart clerks, nurses) +function Map:isCounterCell(cx, cy) + local t = self:cellTile(cx, cy) + for _, c in ipairs(self.tileset.counterTiles or {}) do + if c == t then return true end + end + return false +end + +function Map:warpAtCell(cx, cy) + return self.warpAt[cy * self.widthCells + cx] +end + +function Map:signAtCell(cx, cy) + return self.signAt[cy * self.widthCells + cx] +end + +function Map:connection(dir) + return self.def.connections and self.def.connections[dir] +end + +return Map diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua new file mode 100644 index 00000000..dbeb331d --- /dev/null +++ b/src/world/MapLoader.lua @@ -0,0 +1,30 @@ +-- Builds runtime Map objects (and their tile SpriteBatches) from generated +-- data, cached by map id. + +local Map = require("src.world.Map") +local TileRenderer = require("src.render.TileRenderer") + +local MapLoader = {} + +local cache = {} + +function MapLoader.load(data, mapId) + if cache[mapId] then return cache[mapId] end + local def = data.maps[mapId] + assert(def, "unknown map: " .. tostring(mapId)) + local tilesetDef = data.tilesets[def.tileset] + assert(tilesetDef, "unknown tileset: " .. tostring(def.tileset)) + + -- warp tiles are stored per tileset macro name; the generated tilesets + -- module carries them in the tileset entry itself + local map = Map.new(def, tilesetDef) + map.renderer = TileRenderer.new(map) + cache[mapId] = map + return map +end + +function MapLoader.clearCache() + cache = {} +end + +return MapLoader diff --git a/src/world/NPC.lua b/src/world/NPC.lua new file mode 100644 index 00000000..395e4469 --- /dev/null +++ b/src/world/NPC.lua @@ -0,0 +1,109 @@ +-- Map object (NPC/item) built from a generated object_event entry. +-- STAY objects keep their facing; WALK objects wander randomly within the +-- roam constraint (ANY_DIR / UP_DOWN / LEFT_RIGHT), like the original. + +local Collision = require("src.world.Collision") +local SpriteRenderer = require("src.render.SpriteRenderer") + +local NPC = {} +NPC.__index = NPC + +local STEP_FRAMES = 16 + +local FACING_FROM_RANGE = { + DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", +} + +local ROAM_DIRS = { + ANY_DIR = { "up", "down", "left", "right" }, + UP_DOWN = { "up", "down" }, + LEFT_RIGHT = { "left", "right" }, +} + +function NPC.new(data, mapId, objDef) + local self = setmetatable({}, NPC) + self.def = objDef + self.id = string.format("%s_obj_%d", mapId, objDef.index) + local spriteDef = data.sprites[objDef.sprite] + assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite)) + self.sprite = SpriteRenderer.new(spriteDef) + -- object_event coordinates are already walk-grid cells + self.cellX, self.cellY = objDef.x, objDef.y + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.facing = FACING_FROM_RANGE[objDef.range] or "down" + self.moving = false + self.progress = 0 + self.stepFlip = false + self.frozen = false -- scripts freeze NPCs while talking + self.wanders = objDef.movement == "WALK" + self.roamDirs = ROAM_DIRS[objDef.range] or ROAM_DIRS.ANY_DIR + self.timer = love.math.random(30, 120) + return self +end + +function NPC:facePlayer(player) + local dx = player.cellX - self.cellX + local dy = player.cellY - self.cellY + if math.abs(dx) > math.abs(dy) then + self.facing = dx > 0 and "right" or "left" + else + self.facing = dy > 0 and "down" or "up" + end +end + +function NPC:update(map, entities) + if self.moving then + self.progress = self.progress + 1 + -- NPC_CHANGE_FACING: animate the walk cycle in place, no translation + -- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay + -- pinned to the current cell while walkPhase() cycles. + if self.marching then + if self.progress >= STEP_FRAMES then + self.progress = 0 + self.moving = false + self.marching = false + self.stepFlip = not self.stepFlip + end + return + end + local d = Collision.DELTA[self.facing] + self.px = self.cellX * 16 + d[1] * self.progress + self.py = self.cellY * 16 + d[2] * self.progress + if self.progress >= STEP_FRAMES then + self.cellX, self.cellY = self.targetX, self.targetY + self.targetX, self.targetY = nil, nil + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.moving = false + self.stepFlip = not self.stepFlip + end + return + end + if self.frozen or not self.wanders then return end + self.timer = self.timer - 1 + if self.timer > 0 then return end + self.timer = love.math.random(30, 180) + local dir = self.roamDirs[love.math.random(#self.roamDirs)] + self.facing = dir + if love.math.random() < 0.5 then return end -- sometimes just turn + -- never wander onto warps, so NPCs don't walk out of the map + local tx, ty = Collision.target(self.cellX, self.cellY, dir) + if map:warpAtCell(tx, ty) then return end + if Collision.canMove(map, entities, self, dir) then + self.targetX, self.targetY = tx, ty + self.moving = true + self.progress = 0 + end +end + +function NPC:walkPhase() + if not self.moving then return 0 end + local p = self.progress % 16 + return (p >= 4 and p < 12) and 1 or 0 +end + +function NPC:draw(camX, camY) + self.sprite:draw(self.px, self.py, camX, camY, self.facing, + self:walkPhase(), self.stepFlip) +end + +return NPC diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua new file mode 100644 index 00000000..1bba3b03 --- /dev/null +++ b/src/world/OverworldController.lua @@ -0,0 +1,2975 @@ +-- The overworld state: renders the current map (plus connected map +-- strips), runs the player, NPCs, warps, connections, encounters, ledges, +-- surfing, Cut trees, trainer sight lines, and dispatches interactions to +-- map scripts (data/scripts/), marts, nurses or extracted text. + +local Camera = require("src.render.Camera") +local Collision = require("src.world.Collision") +local Encounter = require("src.world.Encounter") +local Logger = require("src.core.Logger") +local MapLoader = require("src.world.MapLoader") +local NPC = require("src.world.NPC") +local PaletteFX = require("src.render.PaletteFX") +local Player = require("src.world.Player") +local ScriptRunner = require("src.script.ScriptRunner") +local Tilt = require("src.render.Tilt") +local TextBox = require("src.render.TextBox") +local Transition = require("src.render.Transition") +local Warp = require("src.world.Warp") + +local OverworldState = { isOpaque = true } + +local Game -- set on enter (avoids circular require at load time) + +local mapScripts -- registry of hand-ported map scripts + +local COMPASS = { up = "north", down = "south", left = "west", right = "east" } +local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- healing machine ball screen positions (PokeCenterOAMData dbsprite +-- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin +-- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP +local HEAL_BALL_XY = { + { 40, 27 }, { 48, 27, true }, + { 40, 32 }, { 48, 32, true }, + { 40, 37 }, { 48, 37, true }, +} + +-- object_event spawn filter (toggleable_objects, items taken, beaten +-- static encounters), shared by the current map's real NPCs and the +-- visual-only ghosts on connected neighbor maps +local function objectVisible(save, mapId, obj) + local toggles = save.objectToggles and save.objectToggles[mapId] or {} + local visible = not obj.hidden + if obj.name and toggles[obj.name] ~= nil then + visible = toggles[obj.name] + end + if obj.item and save.itemsTaken + and save.itemsTaken[mapId .. "_obj_" .. obj.index] then + visible = false + end + if obj.pokemon and save.defeatedTrainers[mapId .. "_obj_" .. obj.index] then + visible = false + end + return visible +end +OverworldState.objectVisible = objectVisible -- exposed for tests + reuse + +-- NPC instance pool: one NPC object per map object, keyed by the +-- NPC.id format ("_obj_"). The same instance serves as +-- a neighbor-map ghost and as the real NPC once that map is entered, +-- so positions/facings carry across connection seams. +local function pooledNPC(pool, data, mapId, obj) + local key = mapId .. "_obj_" .. obj.index + local npc = pool[key] + if not npc then + npc = NPC.new(data, mapId, obj) + pool[key] = npc + end + return npc +end +OverworldState.pooledNPC = pooledNPC -- exposed for tests + +-- connection hops rendered around the current map: two, so +-- corner-adjacent maps (connections of connections) don't pop in and +-- out of the survey zoom at the seams +local NEIGHBOR_HOPS = 2 + +-- Neighbor placement (pure; exposed for tests): walk the connection +-- graph `hops` connections out, composing the strip offsets, deduped +-- by map id (BFS, so a direct connection always wins over a two-hop +-- path). Offsets are world pixels; connection offsets are in blocks +-- (32 px), the same alignment the connection macro encodes +-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for +-- north/south, _y = offset * -2 for west/east). +function OverworldState.computeNeighbors(maps, rootId, hops) + local out = {} + local placed = { [rootId] = true } + local queue = { { def = maps[rootId], ox = 0, oy = 0, hops = 0 } } + local qi = 1 + while queue[qi] do + local cur = queue[qi] + qi = qi + 1 + for dir, conn in pairs(cur.def.connections or {}) do + local destDef = maps[conn.map] + if destDef and not placed[conn.map] then + placed[conn.map] = true + local ox, oy + if dir == "north" then + ox, oy = conn.offset * 32, -destDef.height * 32 + elseif dir == "south" then + ox, oy = conn.offset * 32, cur.def.height * 32 + elseif dir == "west" then + ox, oy = -destDef.width * 32, conn.offset * 32 + else + ox, oy = cur.def.width * 32, conn.offset * 32 + end + ox, oy = cur.ox + ox, cur.oy + oy + table.insert(out, { id = conn.map, ox = ox, oy = oy }) + if cur.hops + 1 < hops then + table.insert(queue, + { def = destDef, ox = ox, oy = oy, + hops = cur.hops + 1 }) + end + end + end + end + return out +end + +function OverworldState:enter(mapId, x, y, facing) + Game = require("src.core.Game") + Game.overworld = self + Collision.load(Game.data) -- tile-pair (elevation) collisions + mapScripts = require("data.scripts.init") + self.camera = Camera.new() + self.runner = ScriptRunner.new(Game, self) + self.scriptMoves = {} + -- one-shot trainer-engagement state: must not survive a save/load or + -- a fresh entry, or a stale flag can freeze player input forever + self.engaging = false + self.emote = nil + -- survives save/load: a loaded game may start inside a building whose + -- exit mat is a LAST_MAP warp + self.lastOutdoor = Game.save.lastOutdoor + self.justWarped = false + self:setMap(mapId, x, y, facing) +end + +function OverworldState:setMap(mapId, x, y, facing, opts) + self.map = MapLoader.load(Game.data, mapId) + -- STRENGTH deactivates on every real map load (home/overworld.asm + -- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE + -- of wStatusFlags1). setMap is the single choke point for every map-id + -- change -- warps and seamless connection crossings alike -- so an + -- unconditional reset here reproduces that default clear path. It is + -- deliberately NOT part of Game.save: the flag lives in plain WRAM, not + -- SRAM, so it must not survive a save/load. Not reset in afterBattle: + -- pokered keeps STRENGTH across a same-map battle return (EnterMap skips + -- the reset when BIT_BATTLE_OVER_OR_BLACKOUT is set). + self.strengthActive = false + -- Cut trees grow back when the map reloads (like the original) + if self.cutBlocks and self.cutBlocks[mapId] then + for _, c in ipairs(self.cutBlocks[mapId]) do + self.map:setBlock(c.bx, c.by, c.block) + end + self.map.renderer:rebuild() + self.cutBlocks[mapId] = nil + end + -- forced dismount only where riding is disallowed (IsBikeRidingAllowed, + -- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the + -- ROUTE_23/INDIGO_PLATEAU map exceptions) + if Game.save.onBike and not self:bikeAllowed(mapId) then + Game.save.onBike = false + end + -- the Route 16/18 gate map scripts clear the Cycling Road's + -- BIT_ALWAYS_ON_BIKE every frame (scripts/Route16Gate1F.asm / + -- Route18Gate1F.asm `res BIT_ALWAYS_ON_BIKE`); entering the gate is + -- the walking exit from the forced-bike stretch + if mapId == "ROUTE_16_GATE_1F" or mapId == "ROUTE_18_GATE_1F" then + Game.save.forcedBike = nil + end + -- leaving the Safari Zone maps ends any running Safari game + if Game.save.safari and mapId:find("SAFARI_ZONE", 1, true) ~= 1 then + Game.save.safari = nil + end + -- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark + -- until FLASH is used; the light persists between the tunnel floors + -- and resets once outside + local darkDef = Game.data.field.darkMaps + self.dark = false + if darkDef then + local isDark = false + for _, m in ipairs(darkDef.maps) do + if m == mapId then isDark = true break end + end + if isDark then + self.dark = not Game.save.flashLit + else + Game.save.flashLit = nil + end + end + if Game.data.field.flyWarps[mapId] then + Game.save.visited = Game.save.visited or {} + Game.save.visited[mapId] = true + end + -- NPC instances persist across connection crossings in self.npcPool + -- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the + -- objects that become the real NPCs when the player crosses the + -- seam, so nothing snaps back to its spawn point in view of the + -- survey zoom. Warps rebuild from scratch, like the original's + -- per-entry sprite init (home/overworld.asm LoadMapHeader + -- .loadSpriteData). + if not (opts and opts.seamless and self.npcPool) then + self.npcPool = {} + end + self.npcs = {} + for _, obj in ipairs(self.map.def.objects) do + if objectVisible(Game.save, mapId, obj) then + local npc = pooledNPC(self.npcPool, Game.data, mapId, obj) + npc.frozen = false + table.insert(self.npcs, npc) + end + end + if self.player then + self.player.cellX, self.player.cellY = x, y + self.player.px, self.player.py = x * 16, y * 16 + self.player.facing = facing or self.player.facing + self.player.moving = false + self.player.targetX, self.player.targetY = nil, nil + else + self.player = Player.new(Game.data, x, y, facing) + end + self.entities = { self.player } + for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end + + -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK + -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7) + if not (opts and opts.keepMusic) then + require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, + self.player.surfing) + end + + -- snap the camera immediately: the overworld doesn't update while a + -- Transition is on top, so a stale camera would show the new map at + -- the old scroll position for the whole fade-in + self.camera:follow(self.player.px, self.player.py, + Game.renderer:worldViewSize()) + + -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers) + local hooks = mapScripts.get(mapId) + if hooks and hooks.onEnter then + hooks.onEnter(Game, self) + end + + -- neighbor maps drawn at the composed connection offsets, two hops + -- out (the GB only ever streamed a 32px strip of the single + -- directly connected map -- home/overworld.asm .loadNewMap) + self.neighbors = {} + for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, + mapId, NEIGHBOR_HOPS)) do + table.insert(self.neighbors, + { map = MapLoader.load(Game.data, n.id), + ox = n.ox, oy = n.oy }) + end + + -- visual-only NPCs on connected maps (survey zoom): same spawn filter + -- as a real map entry, but they never join self.entities -- no sight + -- lines, triggers, dialogue or player collision. Instances are + -- shared with the real-NPC pool, so positions carry across the seam. + self.ghosts = {} + for _, nb in ipairs(self.neighbors) do + local peers = {} + for _, obj in ipairs(nb.map.def.objects) do + if objectVisible(Game.save, nb.map.id, obj) then + local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj) + table.insert(peers, npc) + table.insert(self.ghosts, + { npc = npc, map = nb.map, ox = nb.ox, oy = nb.oy, + peers = peers }) + end + end + end + Logger.info("map: %s at (%d,%d)", mapId, x, y) + -- Route22Gate_Script rewrites wLastMap from the player's Y on entry + -- too (not only on step), so a save/load mid-gate keeps exits correct + self:syncRoute22GateLastMap() +end + +-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): +-- towns use their own palette, routes PAL_ROUTE, interiors the town or +-- route they are in (wLastMap = our lastOutdoor), with tileset and +-- Elite Four special cases. +local TOWN_PALS = { + PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN", + PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN", + LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION", + CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA", + CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO", + SAFFRON_CITY = "SAFFRON", +} + +function OverworldState:paletteNameFor(map) + local ts = map.def.tileset + local id = map.id + if ts == "CEMETERY" then + return "GRAYMON" -- Pokemon Tower / Agatha + 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 + return TOWN_PALS[id] or "ROUTE" + end + local last = self.lastOutdoor and self.lastOutdoor.id or "PALLET_TOWN" + return TOWN_PALS[last] or "ROUTE" +end + +-- UI-pass palette (text boxes and menus tint with the current map) +function OverworldState:sgbPalettes() + local PaletteFX = require("src.render.PaletteFX") + return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map)) +end + +-- World-pass palette zones in world-canvas pixels: each visible map +-- area keeps its own SGB palette (a deliberate step past the original, +-- which recolored the whole screen per map -- see the survey zoom +-- entry in docs/known-differences.md). Border fill inherits the +-- current map's palette. +function OverworldState:sgbWorldZones() + local PaletteFX = require("src.render.PaletteFX") + local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) + if not base then return nil end + local vw, vh = Game.renderer:worldViewSize() + local cam = self.camera + local zones = { { colors = base, x = 0, y = 0, w = vw, h = vh } } + for _, nb in ipairs(self.neighbors) do + local colors = PaletteFX.pal(Game.data, self:paletteNameFor(nb.map)) + if colors then + table.insert(zones, { colors = colors, + x = math.floor(nb.ox - cam.x), + y = math.floor(nb.oy - cam.y), + w = nb.map.def.width * 32, + h = nb.map.def.height * 32 }) + end + end + return zones +end + +function OverworldState:npcByIndex(index) + for _, n in ipairs(self.npcs) do + if n.def.index == index then return n end + end + return nil +end + +-- Bike riding allowlist (field.bikeRiding, from bike_riding_tilesets.asm +-- + IsBikeRidingAllowed's map exceptions); BagMenu's mount check reads +-- the same table. +function OverworldState:bikeAllowed(mapId) + local br = Game.data.field.bikeRiding + if not br then return self.map.def.tileset == "OVERWORLD" end + for _, m in ipairs(br.maps) do + if m == mapId then return true end + end + for _, t in ipairs(br.tilesets) do + if t == self.map.def.tileset then return true end + end + return false +end + +-- The battle transition's dungeon wipe uses the explicit map lists in +-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus +-- inclusive map-id ranges -- faithful to the original's omissions +-- (Victory Road 2F/3F, the Rocket Hideout, Diglett's Cave, ... miss out). +function OverworldState:isDungeonTransitionMap() + local dm = Game.data.field.dungeonTransitionMaps + if not dm then return false end + for _, m in ipairs(dm.maps) do + if m == self.map.id then return true end + end + local idx = self.map.def.index + for _, r in ipairs(dm.ranges) do + local first = Game.data.maps[r.first] + local last = Game.data.maps[r.last] + if first and last and idx >= first.index and idx <= last.index then + return true + end + end + return false +end + +-- Start a battle behind the into-battle transition: flash, then the +-- wipe picked by trainer/level/dungeon (GetBattleTransitionID). +function OverworldState:pushBattle(battle) + local BattleTransition = require("src.render.BattleTransition") + local lead + for _, mon in ipairs(Game.save.party) do + if mon.hp > 0 then lead = mon break end + end + local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0 + -- the battle theme starts with the wipe, not after it + -- (audio/play_battle_music.asm runs before the transition) + if battle.computeMusicKind then + require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) + end + Game.stack:push(BattleTransition.new(Game, function() + Game.stack:push(battle) + end, { + trainer = battle.kind == "trainer", + stronger = lead ~= nil and enemyLevel >= lead.level + 3, + dungeon = self:isDungeonTransitionMap(), + })) +end + +-- ------------------------------------------------------------------------- +-- update +-- ------------------------------------------------------------------------- + +-- Queue a script for a map's onEnter hook to run once it is safe to. A +-- map load (setMap -> onEnter) can happen mid-warp, while the triggering +-- warp command's runner is still suspended-alive; starting a runner there +-- would trip ScriptRunner:run's assert(not isRunning()). So onEnter stashes +-- the script here and update() drains it once the world is idle. +function OverworldState:queueScript(script, extra) + self.pendingScript = { script = script, extra = extra } +end + +function OverworldState:update(dt) + -- deferred cutscene launch (see queueScript): run a queued script only + -- once the triggering warp's transition has finished, its runner has gone + -- dead, and no scripted walk is mid-step. This is how the HALL_OF_FAME + -- room cutscene starts a frame after the Champions Room warp completes. + if self.pendingScript and not self.transitioning + and not self.runner:isRunning() and #self.scriptMoves == 0 then + local pending = self.pendingScript + self.pendingScript = nil + self.runner:run(pending.script, pending.extra) + end + self.runner:update() + -- keep the player sprite in sync with the bike state (the drawer + -- picks the red_bike sheet while riding) + self.player.onBike = Game.save.onBike + if self.dustAnim then + local da = self.dustAnim + da.frames = da.frames - 1 + if da.frames <= 0 then + self.dustAnim = nil + if da.onDone then da.onDone() end + end + end + if self.healAnim then + local ha = self.healAnim + local Music = require("src.core.Music") + if ha.jinglePlaying and not ha.jingleDone then + ha.jingleDone = not Music.oneShotPlaying() + end + local ev = OverworldState.stepHealAnim(ha) + if ev == "ball" then + require("src.core.Sound").play(Game.data, "Healing_Machine") + elseif ev == "jingle" then + ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed") + ha.jingleDone = not ha.jinglePlaying + elseif ev == "done" then + local done = ha.onDone + self.healAnim = nil + if done then done() end + end + return + end + if self.flyAnim then + self.flyAnim.frames = self.flyAnim.frames - 1 + if self.flyAnim.frames <= 0 then + self.flyAnim = nil + self.player.inputLocked = false + local d = self.flyDest + self.flyDest = nil + if d then + -- the bird carries the player in on landing, with its own + -- SFX_FLY (EnterMapAnim .flyAnimation) + self.arriveWarp = "fly" + self:startWarpTo(d.map, d.x, d.y, "down") + end + return + end + end + + -- delayed one-shot SFX (the teleport-in spin's second note) + if self.delaySfx then + self.delaySfx.frames = self.delaySfx.frames - 1 + if self.delaySfx.frames <= 0 then + require("src.core.Sound").play(Game.data, self.delaySfx.key) + self.delaySfx = nil + end + end + + -- the emotion-bubble pause holds the world for a beat + if self.emote then + self.emote.frames = self.emote.frames - 1 + if self.emote.frames <= 0 then + local done = self.emote.onDone + self.emote = nil + if done then done() end + end + self.player:update() + return + end + + for _, npc in ipairs(self.npcs) do + npc:update(self.map, self.entities) + end + + for _, g in ipairs(self.ghosts) do + g.npc:update(g.map, g.peers) + end + + self:updateScriptMoves() + + -- emote is included: a cutscene hold queued from a scriptMove onDone + -- (e.g. Oak's lab Delay3 after his entry walk) is assigned mid-frame, + -- after the early emote return above already missed it. Without this, + -- one frame of handleInput can sneak through -- holding UP during the + -- escort then walks an extra tile before PlayerEntryMovementRLE, and + -- the player lands on desk Oak. + local scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or self.engaging or self.emote + if not scripted and not self.transitioning then + self:checkTrainerSight() + -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and + -- sets wJoyIgnore the instant a trainer engages, before the loop's + -- direction handling (JoypadOverworld runs the map script first) -- + -- the player can never start another step after being spotted. + scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or self.engaging or self.emote + end + if not scripted and not self.transitioning then + self:handleInput() + end + + local stepped = self.player:update() + if stepped and not scripted then + self:onStepComplete() + end + + self.camera:follow(self.player.px, self.player.py, + Game.renderer:worldViewSize()) +end + +-- any direction currently held (hJoyHeld & PAD_CTRL_PAD) +function OverworldState:dirHeld() + local input = Game.input + return input:isDown("up") or input:isDown("down") + or input:isDown("left") or input:isDown("right") +end + +function OverworldState:handleInput() + local input = Game.input + + if input:wasPressed("a") then + self:interact() + return + end + if input:wasPressed("start") then + require("src.core.Sound").play(Game.data, "Start_Menu") + local StartMenu = require("src.ui.StartMenu") + Game.stack:push(StartMenu.new(Game)) + return + end + + for _, dir in ipairs({ "up", "down", "left", "right" }) do + if input:isDown(dir) then + if not self.player.moving and self.player.facing == dir then + if self:checkEdgeExit(dir) then return end + if self:checkLedgeHop(dir) then return end + if self:checkBoulderPush(dir) then return end + end + local result, why = self.player:tryMove(dir, self.map, self.entities) + if result == "blocked" then + -- a collision while standing on a warp square fires the warp + -- when the extra check passes (CheckWarpsCollision: route-gate + -- doorways, dock entrances, ...) + local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, + self.player.cellX, self.player.cellY, dir) + if w then + self:takeWarp(w.def) + return result + end + end + if result == "blocked" and why ~= "entity" then + if (self.bumpCooldown or 0) <= 0 then + require("src.core.Sound").play(Game.data, "Collision") + self.bumpCooldown = 16 + end + end + self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1) + return result + end + end + + -- Cycling Road's downhill pull: with no d-pad held the bike rolls + -- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN) + local fm = Game.data.field.forcedMovement + if fm and Game.save.onBike and not self.player.moving then + for _, m in ipairs(fm.slopeMaps or {}) do + if m == self.map.id then + self.player.facing = "down" + self.player:tryMove("down", self.map, self.entities) + return + end + end + end +end + +-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder): +-- walking into one with STRENGTH in the party pushes it one cell, but +-- only on the second consecutive push attempt (BIT_TRIED_PUSH_BOULDER); +-- SFX_PUSH_BOULDER when the push starts, dust puff + SFX_CUT after. +function OverworldState:checkBoulderPush(dir) + local p = self.player + local fx, fy = Collision.target(p.cellX, p.cellY, dir) + local npc = self:npcAtCell(fx, fy) + if not npc or npc.def.sprite ~= "SPRITE_BOULDER" or npc.moving then + self.boulderTried = nil -- pokered resets when no boulder is in front + return false + end + -- BIT_STRENGTH_ACTIVE (wStatusFlags1): set only by the party-menu + -- STRENGTH action on this map and cleared on every map load. + -- push_boulder.asm TryPushingBoulder gates on nothing else -- it never + -- re-checks the party's moves or badges at push time, so once STRENGTH + -- is activated any party member can push (even if the STRENGTH-knowing + -- mon is later boxed/swapped out). + if not self.strengthActive then return false end + if self.boulderTried ~= npc then + self.boulderTried = npc + return false -- first attempt only arms the push + end + local bx, by = Collision.target(fx, fy, dir) + if not self.map:inBounds(bx, by) then self.boulderTried = nil return false end + if not self.map:isWalkableCell(bx, by) then + -- boulders may be pushed into holes/switch spots that aren't walkable + if not self.map:isWarpTileCell(bx, by) then + self.boulderTried = nil + return false + end + end + if Collision.occupied(self.entities, bx, by, npc) then + self.boulderTried = nil + return false + end + require("src.core.Sound").play(Game.data, "Push_Boulder") + self:scriptMove(npc, dir, 1, function() + self.boulderTried = nil + -- dust smoke + SFX_CUT once the boulder settles (DoBoulderDustAnimation) + self:startDustAnim(fx, fy, function() + require("src.core.Sound").play(Game.data, "Cut") + end) + if self:boulderIntoHole(npc) then return end + local hooks = mapScripts.get(self.map.id) + if hooks and hooks.onBoulderMoved then + hooks.onBoulderMoved(Game, self, npc) + end + end) + return true +end + +-- The dust puff (engine/overworld/dust_smoke.asm AnimateBoulderDust): +-- the 8x8 smoke tile drawn as a 2x2 block over the vacated cell, +-- flickering for 8 steps of ~4 frames. +function OverworldState:startDustAnim(cx, cy, onDone) + self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone } +end + +-- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile +-- in front + matching input direction -> jump two cells. +function OverworldState:checkLedgeHop(dir) + if self.map.def.tileset ~= "OVERWORLD" then return false end + local p = self.player + local standing = self.map:cellTile(p.cellX, p.cellY) + local fx, fy = Collision.target(p.cellX, p.cellY, dir) + if not self.map:inBounds(fx, fy) then return false end + local front = self.map:cellTile(fx, fy) + for _, ledge in ipairs(Game.data.field.ledges) do + if ledge.facing == dir and ledge.input == dir + and ledge.standingTile == standing and ledge.ledgeTile == front then + local lx, ly = Collision.target(fx, fy, dir) + if self.map:inBounds(lx, ly) + and not Collision.occupied(self.entities, lx, ly, p) + and self.map:isWalkableCell(lx, ly) then + require("src.core.Sound").play(Game.data, "Ledge") + p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic) + self:scriptMove(p, dir, 2) + return true + end + end + end + return false +end + +-- walking off the map edge: connection crossing or edge warp (exit mats) +function OverworldState:checkEdgeExit(dir) + local p = self.player + local tx, ty = Collision.target(p.cellX, p.cellY, dir) + if self.map:inBounds(tx, ty) then return false end + + local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir) + if w then + self:takeWarp(w.def) + return true + end + + local conn = self.map:connection(COMPASS[dir]) + if conn then + self:crossConnection(dir, conn) + return true + end + return false +end + +-- Map connections: the connected map's strip offset is in blocks; arriving +-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md). +-- The crossing scrolls continuously: the map data swaps while the player +-- is placed one cell before the entry point (their old world position, +-- which the neighbor strips render identically) and walks the seam step. +function OverworldState:crossConnection(dir, conn) + local dest = Game.data.maps[conn.map] + if not dest then + Logger.warn("connection to unknown map %s", tostring(conn.map)) + return + end + local p = self.player + local x, y = p.cellX, p.cellY + local destW, destH = dest.width * 2, dest.height * 2 + if dir == "up" then + x, y = p.cellX - conn.offset * 2, destH - 1 + elseif dir == "down" then + x, y = p.cellX - conn.offset * 2, 0 + elseif dir == "left" then + x, y = destW - 1, p.cellY - conn.offset * 2 + else + x, y = 0, p.cellY - conn.offset * 2 + end + x = math.max(0, math.min(destW - 1, x)) + y = math.max(0, math.min(destH - 1, y)) + self:setMap(conn.map, x, y, p.facing, { seamless = true }) + -- place the player one cell before the seam (their old world spot, + -- which the neighbor strip renders identically) and start the step + -- into the new map RIGHT NOW so there is no one-frame stall at the + -- boundary (updateScriptMoves already ran this frame; kicking the + -- move here lets player:update animate the first pixel immediately) + local d = DIRVEC[dir] + p.cellX, p.cellY = x - d[1], y - d[2] + p.px, p.py = p.cellX * 16, p.cellY * 16 + self.camera:follow(p.px, p.py) + p.facing = dir + p.targetX, p.targetY = x, y + p.moving = true + p.progress = 0 + p.stepFramesCur = (Game.save.onBike) and 8 or 16 +end + +-- ------------------------------------------------------------------------- +-- interactions +-- ------------------------------------------------------------------------- + +-- HM field moves are gated by badges like the original +local HM_BADGE = { + CUT = "CASCADEBADGE", SURF = "SOULBADGE", STRENGTH = "RAINBOWBADGE", + FLY = "THUNDERBADGE", FLASH = "BOULDERBADGE", +} + +function OverworldState:partyKnows(moveId) + local badge = HM_BADGE[moveId] + if badge and not Game.save.inventory[badge] then + return nil + end + for _, mon in ipairs(Game.save.party) do + if mon.hp > 0 then + for _, mv in ipairs(mon.moves) do + if mv.id == moveId then return mon end + end + end + end + return nil +end + +-- The rejection loop shared by the Good and Super Rods +-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an +-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it +-- lands inside the group, so the bite odds are size/(size+4) +-- (1/3 for the Good Rod's pair, up to 1/2 for 4-mon Super Rod groups). +local function rollFishingGroup(group) + while true do + local r = love.math.random(0, 255) + if r % 2 == 1 then return nil end + local pick = math.floor(r / 2) % 4 + if pick < #group then + local slot = group[pick + 1] + return { species = slot.species, level = slot.level } + end + end +end + +local GOOD_ROD_MONS = { -- data/wild/good_rod.asm + { species = "GOLDEEN", level = 10 }, + { species = "POLIWAG", level = 10 }, +} + +-- Fishing (engine/items/item_effects.asm FishingInit + engine/overworld): +-- Old Rod always hooks a L5 Magikarp; Good Rod bites ~1/3 for +-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group +-- (no group means "Not even a nibble!"). +function OverworldState:goFishing(rod) + local enc + if rod == "OLD_ROD" then + enc = { species = "MAGIKARP", level = 5 } + elseif rod == "GOOD_ROD" then + enc = rollFishingGroup(GOOD_ROD_MONS) + else + local group = Game.data.field.superRod[self.map.id] + if group and #group > 0 then + enc = rollFishingGroup(group) + end + end + -- the bobber waits a beat before the verdict (the original's + -- FishingInit dot animation); the rod pose draws in the meantime + self.fishing = { facing = self.player.facing } + Game.stack:push(TextBox.new(Game, ". . .", function() + self.fishing = nil + if not enc then + Game.stack:push(TextBox.new(Game, "Not even a nibble!")) + return + end + Game.stack:push(TextBox.new(Game, "Oh!\nIt's a bite!", function() + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) + if Game.save.safari and self.map.id:find("SAFARI_ZONE", 1, true) == 1 then + battle:makeSafari(Game.save.safari) + end + battle.onFinish = function(result) self:afterBattle(result) end + self:pushBattle(battle) + end)) + end)) +end + +-- Fly to a visited town (called from the party menu). +function OverworldState:flyTo(mapId) + local spot = Game.data.field.flyWarps[mapId] + if not spot then return end + require("src.core.Sound").play(Game.data, "Fly") + Game.save.onBike = false + Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE + self.player.surfing = false + -- the bird carries the player off westward before the warp + -- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics) + self.flyAnim = { frames = 48 } + self.player.inputLocked = true + self.flyDest = { map = mapId, x = spot.x, y = spot.y } +end + +function OverworldState:npcAtCell(cx, cy) + for _, npc in ipairs(self.npcs) do + if (npc.cellX == cx and npc.cellY == cy) or + (npc.targetX == cx and npc.targetY == cy) then + return npc + end + end + return nil +end + +function OverworldState:interact() + local p = self.player + local fx, fy = p:facingCell() + + local npc = self:npcAtCell(fx, fy) + if not npc and self.map:isCounterCell(fx, fy) then + -- talk across counters (mart clerks, nurses); uses the tileset's + -- counter tiles from tileset_headers.asm + local fx2, fy2 = Collision.target(fx, fy, p.facing) + npc = self:npcAtCell(fx2, fy2) + end + if npc then + if not npc.moving then + self:talkTo(npc) + end + return + end + + local sign = self.map:signAtCell(fx, fy) + if sign then + self:showMapText(sign.text, nil) + return + end + + -- Silph Co card key doors (engine/events/card_key.asm) + if self:tryCardKeyDoor(fx, fy) then return end + + -- hidden items / coins / slot machines / PC tiles / bench guys / + -- gym statues / trash cans (data/events/hidden_events.asm) + if self:tryHiddenObject(fx, fy) then return end + + -- No overworld A-press hook for field moves: pokered has no such hook + -- anywhere -- CUT and SURF (like FLY/FLASH/DIG/TELEPORT/STRENGTH) are + -- only ever chosen from the party menu's per-mon field-move submenu + -- (start_sub_menus.asm .outOfBattleMovePointers), and only succeed if + -- the player happens to be facing a cuttable tree / water at the moment + -- of selection. See PartyMenu's cut/surf actions -> useCutFieldMove / + -- useSurfFieldMove below. + + -- map-script interact hook (hand-ported hidden events like the + -- museum fossil exhibits) + local hooks = mapScripts.get(self.map.id) + if hooks and hooks.onInteract and hooks.onInteract(Game, self, fx, fy) then + return + end + + -- tileset-generic reads (PrintBookshelfText): facing up into a + -- bookshelf/statue/shelf tile prints its stock line + if self:tryBookshelf(fx, fy) then return end +end + +-- data/tilesets/bookshelf_tile_ids.asm: tileset id + collision tile -> +-- text. Only fires facing up, like the original. +local BOOKSHELVES = { + PLATEAU = { [0x30] = "statues" }, + HOUSE = { [0x3D] = "townmap", [0x1E] = "books" }, + MANSION = { [0x32] = "books" }, + REDS_HOUSE_1 = { [0x32] = "books" }, + LAB = { [0x28] = "books" }, + LOBBY = { [0x16] = "elevator", [0x50] = "stuff", [0x52] = "stuff" }, + GYM = { [0x1D] = "books" }, + DOJO = { [0x1D] = "books" }, + GATE = { [0x22] = "books" }, + MART = { [0x54] = "stuff", [0x55] = "stuff" }, + POKECENTER = { [0x54] = "stuff", [0x55] = "stuff" }, + SHIP = { [0x36] = "books" }, +} + +function OverworldState:tryBookshelf(fx, fy) + if self.player.facing ~= "up" then return false end + if not self.map:inBounds(fx, fy) then return false end + local table_ = BOOKSHELVES[self.map.def.tileset] + if not table_ then return false end + local kind = table_[self.map:cellTile(fx, fy)] + if not kind then return false end + local t = Game.data.text + if kind == "books" then + -- Celadon Mansion's Diglett sculpture (book_or_sculpture.asm): + -- MANSION tileset + faced cell's top-left tile $38 + if self.map.def.tileset == "MANSION" + and self.map:tileAt(fx * 2, fy * 2) == 0x38 then + Game.stack:push(TextBox.new(Game, t._DiglettSculptureText + or "It's a sculpture\nof DIGLETT.")) + return true + end + Game.stack:push(TextBox.new(Game, t._PokemonBooksText + or "Crammed full of\nPOKéMON books!")) + elseif kind == "stuff" then + Game.stack:push(TextBox.new(Game, t._PokemonStuffText + or "There's a slew of\nPOKéMON stuff!")) + elseif kind == "elevator" then + Game.stack:push(TextBox.new(Game, t._ElevatorText + or "An elevator!")) + elseif kind == "statues" then + -- IndigoPlateauStatues: the plaque, then one of the two lines + -- keyed by the statue's column (XCoord bit 0) + local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2 + or t._IndigoPlateauStatuesText3 + Game.stack:push(TextBox.new(Game, + (t._IndigoPlateauStatuesText1 or "INDIGO PLATEAU") .. "\f" + .. (line or "POKéMON LEAGUE HQ"))) + elseif kind == "townmap" then + -- Blue's house shelf opens the TOWN MAP (TownMapText) + local ok, TownMap = pcall(require, "src.ui.TownMap") + if ok then + Game.stack:push(TownMap.new(Game)) + end + end + return true +end + +-- Hidden events at the faced cell (data/events/hidden_events.asm): +-- HiddenItems give their item once, HiddenCoins fill the COIN CASE, +-- StartSlotMachine seats open the minigame. Taken spots persist in +-- save.hiddenTaken. +function OverworldState:tryHiddenObject(fx, fy) + local field = Game.data.field + local save = Game.save + local key = self.map.id .. "_" .. fx .. "_" .. fy + + for _, h in ipairs(field.hiddenItems and field.hiddenItems[self.map.id] or {}) do + if h.x == fx and h.y == fy then + save.hiddenTaken = save.hiddenTaken or {} + if save.hiddenTaken[key] then return false end + if not require("src.inventory.Bag").add(save, h.item, 1) then + Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!")) + return true + end + save.hiddenTaken[key] = true + local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item + -- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) + require("src.core.Sound").play(Game.data, "Get_Item2") + Game.stack:push(TextBox.new(Game, + ("%s found\n%s!"):format(save.player.name, name))) + return true + end + end + + for _, h in ipairs(field.hiddenCoins and field.hiddenCoins[self.map.id] or {}) do + if h.x == fx and h.y == fy then + save.hiddenTaken = save.hiddenTaken or {} + if save.hiddenTaken[key] then return false end + if not save.inventory.COIN_CASE then return false end + save.hiddenTaken[key] = true + save.coins = math.min(9999, (save.coins or 0) + h.coins) + require("src.core.Sound").play(Game.data, "Get_Item2") + Game.stack:push(TextBox.new(Game, + ("%s found\n%d coins!"):format(save.player.name, h.coins))) + return true + end + end + + -- broken-machine and can't-play texts are pokered's exact strings + -- (_GameCornerOutOfOrderText etc., data/text/text_2.asm) + local txt = Game.data.text or {} + for seatIndex, h in ipairs(field.slotMachines and field.slotMachines[self.map.id] or {}) do + if h.x == fx and h.y == fy then + if h.state == "out_of_order" then + Game.stack:push(TextBox.new(Game, txt._GameCornerOutOfOrderText + or "OUT OF ORDER\nThis is broken.")) + elseif h.state == "out_to_lunch" then + Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText + or "OUT TO LUNCH\nThis is reserved.")) + elseif h.state == "keys" then + Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText + or "Someone's keys!\nThey'll be back.")) + elseif not save.inventory.COIN_CASE then + Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText + or "A COIN CASE is\nrequired!")) + elseif (save.coins or 0) == 0 then + -- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play + Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText + or "You don't have\nany coins!")) + else + -- one machine per visit is secretly lucky + -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) + local SlotMachine = require("src.ui.SlotMachine") + Game.stack:push(SlotMachine.new(Game, seatIndex == self.luckySlot)) + end + return true + end + end + + local extras = field.hiddenExtras + if not extras then return false end + local facing = self.player.facing + + -- Pokémon Center PCs and other PC tiles + for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do + if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then + self:openPC() + return true + end + end + + -- bench guys (data/events/bench_guys.asm) + for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do + if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then + local text = h.text and Game.data.text["_" .. h.text] + if text then + Game.stack:push(TextBox.new(Game, text)) + return true + end + end + end + + -- gym statues (engine/events/hidden_events/gym_statues.asm): show + -- the gym plaque; the player's name joins the winners once the + -- badge is earned + for _, h in ipairs(extras.gymStatues[self.map.id] or {}) do + if h.x == fx and h.y == fy and facing == "up" then + local gym = require("data.scripts.gyms")[self.map.id] + if gym then + local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1" + local text = Game.data.text[key] + or "{RAM}\nPOKéMON GYM\nLEADER: {RAM}" + text = text:gsub("{RAM:wGymCityName}", gym.city) + :gsub("{RAM:wGymLeaderName}", gym.leader) + Game.stack:push(TextBox.new(Game, text)) + return true + end + end + end + + -- the Vermilion Gym trash can lock puzzle + if self.map.id == "VERMILION_GYM" then + for _, h in ipairs(extras.trashCans.cans or {}) do + if h.x == fx and h.y == fy then + self:trashCanSwitch(h.can) + return true + end + end + end + + return false +end + +-- Card key doors: on the Silph Co maps, facing a locked-door tile with +-- the CARD KEY replaces the door block with the open one +-- (engine/events/card_key.asm PrintCardKeyText). +function OverworldState:tryCardKeyDoor(fx, fy) + local ck = Game.data.field.cardKeyDoors + if not ck then return false end + local onList = false + for _, m in ipairs(ck.maps) do + if m == self.map.id then onList = true break end + end + if not onList or not self.map:inBounds(fx, fy) then return false end + local tile = self.map:cellTile(fx, fy) + local openBlock + if self.map.id == "SILPH_CO_11F" then + if tile == ck.silphCo11F.doorTile then openBlock = ck.silphCo11F.openBlock end + else + for _, t in ipairs(ck.doorTiles) do + if tile == t then openBlock = ck.openBlock break end + end + end + if not openBlock then return false end + local t = Game.data.text + if not Game.save.inventory.CARD_KEY then + Game.stack:push(TextBox.new(Game, + t._CardKeyFailText or "Darn! It needs a\nCARD KEY!")) + return true + end + require("src.core.Sound").play(Game.data, "Go_Inside") + self:replaceBlock(math.floor(fx / 2), math.floor(fy / 2), openBlock) + Game.stack:push(TextBox.new(Game, + (t._CardKeySuccessText1 or "Bingo!") + .. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!"))) + return true +end + +-- The Vermilion Gym trash can puzzle +-- (engine/events/hidden_events/vermilion_gym_trash.asm GymTrashScript): +-- the first switch hides in a random even can, rolled on every +-- Vermilion City map load (scripts/VermilionCity.asm VermilionCity_Script +-- .setFirstLockTrashCanIndex -- see M.VERMILION_CITY.onEnter in +-- data/scripts/story.lua) and re-rolled on every failed second-can +-- guess; the second switch is drawn from the GymTrashCans candidate +-- table (bug included). Opening both unlocks the door block at (2,2) +-- (scripts/VermilionGym.asm VermilionGymSetDoorTile). +function OverworldState:trashCanSwitch(canIndex) + local t = Game.data.text + local save = Game.save + local tc = Game.data.field.hiddenExtras.trashCans + local trashText = t._VermilionGymTrashText or "Nope, there's\nonly trash here." + -- "Don't do the trash can puzzle if it's already been done." + if save.flags.EVENT_2ND_LOCK_OPENED then + Game.stack:push(TextBox.new(Game, trashText)) + return + end + save.trashPuzzle = save.trashPuzzle or {} + local puz = save.trashPuzzle + if puz.opened1 then + -- migrate mid-puzzle saves from before the port tracked the real + -- EVENT_1ST_LOCK_OPENED flag + save.flags.EVENT_1ST_LOCK_OPENED = true + puz.opened1 = nil + end + if not puz.first then + -- normally rolled by Vermilion City's map load (the only way in); + -- covers saves from before that hook and debug warps straight in + puz.first = love.math.random(0, 7) * 2 -- Random & $0e: even cans + end + if not save.flags.EVENT_1ST_LOCK_OPENED then + if canIndex ~= puz.first then + Game.stack:push(TextBox.new(Game, trashText)) + return + end + -- .openFirstLock: SetEvent EVENT_1ST_LOCK_OPENED, then pick where + -- the second switch hides. GymTrashCans rows are `mask, + -- cand1..cand4` where the mask doubles as the candidate count + -- (2, 3 or 4). The asm ANDs the mask with a random byte (its + -- nibble swap is distribution-neutral) and uses `result - 1` as a + -- byte offset into the candidates: + -- mask 3: result 1-3 -> candidate 1-3 + -- mask 2: result 2 -> candidate 2 (candidate 1 unreachable) + -- mask 4: result 4 -> candidate 4 (candidates 1-3 unreachable) + -- result 0: `dec a` underflows to $ff and the read lands on the + -- ROM bank's zero padding, so the second switch lands in can 0 + -- regardless of adjacency (the documented GymTrashCans bug) + save.flags.EVENT_1ST_LOCK_OPENED = true + local adj = tc.adjacent[puz.first] + local masked = require("bit").band(love.math.random(0, 255), #adj) + puz.second = masked == 0 and 0 or adj[masked] + -- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only + -- after the text has printed (text_far ...; text_asm; + -- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish), + -- and DisplayTextID's WaitForTextScrollButtonPress then holds the box + -- until the player dismisses it -- so the beep belongs on close, not + -- open. + Game.stack:push(TextBox.new(Game, + t._VermilionGymTrashSuccessText1 + or "Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!", + function() require("src.core.Sound").play(Game.data, "Switch") end)) + return + end + -- .trySecondLock + if canIndex == puz.second then + -- .openSecondLock: only VermilionGymTrashSuccessText3 prints + -- (SuccessText2 is unused in pokered) + save.flags.EVENT_2ND_LOCK_OPENED = true + self:replaceBlock(2, 2, 5) -- clear floor block opens the doors + -- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text + -- prints, so the beep fires as the box closes, not as it opens. + Game.stack:push(TextBox.new(Game, + t._VermilionGymTrashSuccessText3 + or "The 2nd electric\nlock opened!\fThe motorized door\nopened!", + function() require("src.core.Sound").play(Game.data, "Go_Inside") end)) + else + -- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately + -- re-roll the first switch (Random & $e) + save.flags.EVENT_1ST_LOCK_OPENED = nil + puz.first = love.math.random(0, 7) * 2 + puz.second = nil + -- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the + -- text prints, so the beep fires as the box closes, not as it opens. + Game.stack:push(TextBox.new(Game, + t._VermilionGymTrashFailText + or "Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!", + function() require("src.core.Sound").play(Game.data, "Denied") end)) + end +end + +-- Any hidden item still unfound NEAR the player? (the ITEMFINDER, +-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5) +-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes +-- coordinate 0 whenever the player coordinate is <= 4, like the original) +function OverworldState:hasHiddenItemLeft() + local list = Game.data.field.hiddenItems and Game.data.field.hiddenItems[self.map.id] + if not list then return false end + local taken = Game.save.hiddenTaken or {} + local px, py = self.player.cellX, self.player.cellY + local function near(c, v, hiAdd) + return v > math.max(c - 5, 0) and v <= c + hiAdd + end + for _, h in ipairs(list) do + if not taken[self.map.id .. "_" .. h.x .. "_" .. h.y] + and near(py, h.y, 4) and near(px, h.x, 5) then + return true + end + end + return false +end + +function OverworldState:tilesetHasWater() + for _, t in ipairs(Game.data.field.waterTilesets) do + if t == self.map.def.tileset then return true end + end + return false +end + +-- Gen 1 has no confirmation prompt: using SURF gets straight on +-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party +-- menu's SURF action (via useSurfFieldMove) once the facing tile has been +-- confirmed to be water -- there is no overworld A-press hook. +function OverworldState:trySurf(fx, fy) + local mon = self:partyKnows("SURF") + if not mon then return end + local name = mon.nickname or Game.data.pokemon[mon.species].name + local p = self.player + p.surfing = true + require("src.core.Music").setSurfing(Game.data, true) + local text = (Game.data.text._SurfingGotOnText or "{PLAYER} got on\n{RAM:wNameBuffer}!") + :gsub("{RAM:wNameBuffer}", name) + Game.stack:push(TextBox.new(Game, text, function() + -- start_sub_menus.asm .surf: UseItem returns (mount + text done), + -- then GBPalWhiteOutWithDelay3 blinks before the simulated forward + -- press steps onto the water + local Transition = require("src.render.Transition") + if Transition.whiteFlash then + Game.stack:push(Transition.whiteFlash(Game, nil, function() + self:scriptMove(p, p.facing, 1) + end)) + else + self:scriptMove(p, p.facing, 1) + end + end)) +end + +function OverworldState:tryCut(fx, fy) + local bx, by = math.floor(fx / 2), math.floor(fy / 2) + local block = self.map:blockAt(bx, by) + local swap + for _, sw in ipairs(Game.data.field.cutTreeSwaps) do + if sw.before == block then swap = sw break end + end + if not swap or self.map:isWalkableCell(fx, fy) then return false end + local mon = self:partyKnows("CUT") + if not mon then return false end + -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the + -- _UsedCutText message, then the tree vanishes with dust + SFX_CUT + local name = mon.nickname or Game.data.pokemon[mon.species].name + local text = (Game.data.text._UsedCutText or "{RAM:wNameBuffer} hacked\naway with CUT!") + :gsub("{RAM:wNameBuffer}", name) + Game.stack:push(TextBox.new(Game, text, function() + self.cutBlocks = self.cutBlocks or {} + self.cutBlocks[self.map.id] = self.cutBlocks[self.map.id] or {} + table.insert(self.cutBlocks[self.map.id], + { bx = bx, by = by, block = block }) + self.map:setBlock(bx, by, swap.after) + self.map.renderer:rebuild() + self:startDustAnim(fx, fy, function() + require("src.core.Sound").play(Game.data, "Cut") + end) + end)) + return true +end + +-- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE, +-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard +-- (item_effects.asm), which either tries to dismount (already surfing) or +-- runs IsNextTileShoreOrWater on the tile the player is FACING and jumps +-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is +-- a side-effect-free check that reports which text/flow the caller should +-- use; the actual mount happens in trySurf on "ok". Returns: +-- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText) +-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText) +-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText) +-- "dismount" -> already surfing, facing dry land; caller steps forward +-- "no_place" -> already surfing, nowhere to land (_SurfingNoPlaceToGetOffText) +-- "no_water" -> not facing water (_NoSurfingHereText) +-- "ok" -> facing water; caller may call trySurf(fx, fy) +function OverworldState:useSurfFieldMove() + if not self:partyKnows("SURF") then return "no_badge" end + local p = self.player + -- IsSurfingAllowed (engine/overworld/field_move_messages.asm): surfing + -- is refused while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set (the + -- Cycling Road, armed by the forced-bike tiles and cleared by the + -- Route 16/18 gate scripts / fly + dungeon warps / blackouts), and on + -- SEAFOAM_ISLANDS_B4F standing on the stairs square (dbmapcoord 7,11) + -- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set. + if Game.save.forcedBike then return "forced_bike" end + if self.map.id == "SEAFOAM_ISLANDS_B4F" + and not (Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] + and Game.save.flags["EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"]) + and p.cellX == 7 and p.cellY == 11 then + return "current" + end + local fx, fy = p:facingCell() + if p.surfing then + -- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front + -- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a + -- facing tile that isn't in the tileset's land-passable list; + -- otherwise the player walks forward off the water. + if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy) + and Collision.canMove(self.map, self.entities, p, p.facing) then + return "dismount" + end + return "no_place" + end + if not self.map:inBounds(fx, fy) + or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then + return "no_water" + end + return "ok" +end + +-- Party-menu CUT entry (start_sub_menus.asm .cut -> predef UsedCut, +-- engine/overworld/cut.asm): badge-check CASCADEBADGE then check the tile +-- the player is FACING against the tileset's cut-tree ids; _NothingToCutText +-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free +-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok". +-- Returns: +-- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText) +-- "nothing" -> not facing a cuttable tree (_NothingToCutText) +-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy) +function OverworldState:useCutFieldMove() + if not self:partyKnows("CUT") then return "no_badge" end + local fx, fy = self.player:facingCell() + if not self.map:inBounds(fx, fy) then return "nothing" end + local bx, by = math.floor(fx / 2), math.floor(fy / 2) + local block = self.map:blockAt(bx, by) + local swap + for _, sw in ipairs(Game.data.field.cutTreeSwaps) do + if sw.before == block then swap = sw break end + end + if not swap or self.map:isWalkableCell(fx, fy) then return "nothing" end + return "ok" +end + +function OverworldState:talkTo(npc) + npc.frozen = true + local unfreeze = function() npc.frozen = false end + local d = npc.def + + -- hand-ported scripts always win + if mapScripts.talkScript(self.map.id, d.text) then + self:showMapText(d.text, npc, unfreeze) + return + end + + -- item balls (object_event item argument) + if d.item then + if not require("src.inventory.Bag").add(Game.save, d.item, 1) then + Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!")) + return + end + Game.save.itemsTaken = Game.save.itemsTaken or {} + Game.save.itemsTaken[npc.id] = true + for i, n in ipairs(self.npcs) do + if n == npc then table.remove(self.npcs, i) break end + end + for i, e in ipairs(self.entities) do + if e == npc then table.remove(self.entities, i) break end + end + local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item + local ddef = Game.data.items[d.item] + require("src.core.Sound").play(Game.data, + (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1") + Game.stack:push(TextBox.new(Game, + ("%s found\n%s!"):format(Game.save.player.name, name))) + return + end + + -- static wild encounters (object_event species+level args: the + -- legendary birds, Mewtwo, the Vermilion Machop, ...) + if d.pokemon then + npc:facePlayer(self.player) + local text = select(1, Game.data:resolveText(self.map.def.label, d.text)) + or "Gyaoo!" + local BattleState = require("src.battle.BattleState") + Game.stack:push(TextBox.new(Game, text, function() + local battle = BattleState.newWild(Game, d.pokemon, d.level) + battle.onFinish = function(result) + if result ~= "lose" and result ~= "run" then + Game.save.defeatedTrainers[npc.id] = true + for i, n in ipairs(self.npcs) do + if n == npc then table.remove(self.npcs, i) break end + end + for i, e in ipairs(self.entities) do + if e == npc then table.remove(self.entities, i) break end + end + end + self:afterBattle(result) + unfreeze() + end + self:pushBattle(battle) + end)) + return + end + + -- generic trainers (object_event trainer args + extracted headers) + if d.trainerClass and not self:trainerDefeated(npc) then + npc:facePlayer(self.player) + self:engageTrainer(npc, unfreeze) + return + end + if d.trainerClass and self:trainerDefeated(npc) then + local header = Game.data:trainerHeader(self.map.def.label, d.index) + local after = header and header.after and Game.data.text[header.after] + if after then + npc:facePlayer(self.player) + Game.stack:push(TextBox.new(Game, after, unfreeze)) + return + end + end + + -- marts / nurses / PCs via TX_SCRIPT markers + local entry = Game.data:textEntry(self.map.def.label, d.text) + if entry then + if entry.mart then + npc:facePlayer(self.player) + local ShopMenu = require("src.ui.ShopMenu") + Game.stack:push(TextBox.new(Game, "Hi there!\nMay I help you?", function() + Game.stack:push(ShopMenu.new(Game, entry.mart)) + unfreeze() + end)) + return + end + if entry.nurse then + npc:facePlayer(self.player) + self:nurseHeal(unfreeze, npc) + return + end + if entry.pc then + self:openPC(unfreeze) + return + end + if entry.cableClub then + npc:facePlayer(self.player) + self:cableClubReceptionist(unfreeze) + return + end + end + + self:showMapText(d.text, npc, unfreeze) +end + +-- The Pokémon Center PC: BILL's PC (boxes), the player's item storage, +-- and PROF.OAK's dex rating (engine/menus/players_pc.asm, +-- engine/events/pokedex_rating.asm). +function OverworldState:openPC(onDone) + require("src.core.Sound").play(Game.data, "Turn_On_PC") + local Menu = require("src.ui.Menu") + local done = onDone or function() end + local flags = Game.save.flags or {} + local items = {} + + -- the box PC reads "SOMEONE'S PC" until you meet Bill, then "BILL'S PC" + -- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that + -- when Bill hands over the SS Ticket) + local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET + table.insert(items, { + label = metBill and "BILL'S PC" or "SOMEONE'S PC", + onSelect = function() + require("src.core.Sound").play(Game.data, "Enter_PC") + local BoxMenu = require("src.ui.BoxMenu") + Game.stack:push(BoxMenu.new(Game)) + done() + end, + }) + + -- the player's item storage is always available + table.insert(items, { + label = (Game.save.player.name or "RED") .. "'s PC", + onSelect = function() + local PlayerPC = require("src.ui.PlayerPC") + Game.stack:push(PlayerPC.new(Game)) + done() + end, + }) + + -- Prof. Oak's dex rating only appears once you have the Pokédex + if flags.EVENT_GOT_POKEDEX then + table.insert(items, { + label = "PROF.OAK's PC", + onSelect = function() + self:dexRating() + done() + end, + }) + end + + local logOff = function() + require("src.core.Sound").play(Game.data, "Turn_Off_PC") + done() + end + table.insert(items, { label = "LOG OFF", onSelect = logOff }) + -- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session + -- (engine/overworld/pokecenter_pc.asm / player_pc.asm) + Game.stack:push(Menu.new(Game, items, + { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, onCancel = logOff, + noSound = true })) +end + +-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm): +-- the completion line with seen AND owned counts, then the per-decade +-- rating text. +function OverworldState:dexRating() + require("src.core.Sound").play(Game.data, "Pokedex_Rating") + local seen, owned = 0, 0 + for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end + for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end + local key + if owned >= 150 then + key = "_DexRatingText_Own150To151" + else + local lo = math.floor(owned / 10) * 10 + key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9) + end + local rating = Game.data.text[key] or "Keep it up!" + local completion = Game.data.text._DexCompletionText + or "POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:" + completion = completion + :gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen)) + :gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned)) + Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating)) +end + +-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the +-- monitor lights, then one ball per party mon appears every 30 frames +-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine +-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame +-- beat once the jingle ends. Pure per-frame step over the ha table +-- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/ +-- "done" when the caller must fire the matching side effect. +function OverworldState.stepHealAnim(ha) + ha.timer = ha.timer + 1 + ha.phase = ha.phase or "balls" + if ha.phase == "balls" then + -- .partyLoop: a ball lights with the machine sfx, then 30 frames + if ha.lit == 0 or ha.timer >= 30 then + ha.timer = 0 + if ha.lit < ha.balls then + ha.lit = ha.lit + 1 + return "ball" + end + ha.phase = "flash" + ha.flashes = 0 + return "jingle" + end + elseif ha.phase == "flash" then + -- FlashSprite8Times: xor the OBJ palette every 10 frames, 8 times + if ha.timer >= 10 then + ha.timer = 0 + ha.visible = not ha.visible + ha.flashes = ha.flashes + 1 + if ha.flashes >= 8 then + ha.phase = "wait" + ha.visible = true + end + end + elseif ha.phase == "wait" then + -- .waitLoop2: hold until the jingle ends, then 32 more frames + if not ha.jingleDone then + ha.timer = 0 + elseif ha.timer >= 32 then + return "done" + end + end +end + +-- Nurse dialogue uses the real engine strings (data/text/text_4.asm via +-- engine/events/pokecenter.asm): welcome (plus "Shall we heal" the first +-- time), a YES/NO, then the machine animation between "we need your +-- POKéMON" and "fighting fit". +function OverworldState:nurseHeal(onDone, npc) + local t = Game.data.text + local bye = t._PokemonCenterFarewellText or "We hope to see\nyou again!" + local hello = t._PokemonCenterWelcomeText + or "Welcome to our\nPOKéMON CENTER!" + if not Game.save.usedPokecenter then + Game.save.usedPokecenter = true -- BIT_USED_POKECENTER + hello = hello .. "\f" + .. (t._ShallWeHealYourPokemonText or "Shall we heal your\nPOKéMON?") + end + Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) + if not yes then + Game.stack:push(TextBox.new(Game, bye, onDone)) + return + end + local need = t._NeedYourPokemonText or "OK. We'll need\nyour POKéMON." + Game.stack:push(TextBox.new(Game, need, function() + -- the nurse turns to the machine, the map music stops, and the + -- party heals before the machine runs (predef HealParty) + if npc then npc.facing = "left" end + require("src.core.Music").stop() + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(Game.save.party) do + Pokemon.heal(mon) + end + Game.save.lastHeal = { -- SetLastBlackoutMap + map = self.map.id, x = self.player.cellX, y = self.player.cellY, + -- the town door of this interior, for LAST_MAP exits after a + -- blackout/ESCAPE ROPE warp here + outdoor = self.lastOutdoor + and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y } + or nil, + } + self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0, + visible = true, + -- map anchor: the player's cell when healing + -- began (the GB's fixed screen coords assume it + -- BG-aligned at (64,64)) + px = self.player.cellX * 16, + py = self.player.cellY * 16 } + self.healAnim.onDone = function() + if npc then npc:facePlayer(self.player) end + self:finishNurseHeal(bye, onDone) + end + end)) + end })) +end + +function OverworldState:finishNurseHeal(bye, onDone) + local t = Game.data.text + local fit = t._PokemonFightingFitText or "Your POKéMON are\nfighting fit!" + Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone)) +end + +-- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST -> +-- CableClubNPC, engine/link/cable_club_npc.asm): the welcome line, then +-- without the POKéDEX she's still "making preparations"; with it she asks +-- to apply (YES/NO), saves the game (SaveGameData + SFX_SAVE) and opens +-- the link. The port's enet link menu (src/link/LinkState.lua) stands in +-- for the original serial handshake; declining prints "Please come again!" +function OverworldState:cableClubReceptionist(onDone) + local t = Game.data.text + local welcome = t._CableClubNPCWelcomeText or "Welcome to the\nCable Club!" + if not Game.save.flags.EVENT_GOT_POKEDEX then + -- CableClubNPC .didNotConnect path before the pokedex + Game.stack:push(TextBox.new(Game, welcome .. "\f" + .. (t._CableClubNPCMakingPreparationsText + or "We're making\npreparations.\vPlease wait."), onDone)) + return + end + local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText + or "Please apply here.\fBefore opening\nthe link, we have\vto save the game." + Game.stack:push(TextBox.new(Game, welcome .. "\f" .. apply, nil, + { choice = function(yes) + if not yes then + Game.stack:push(TextBox.new(Game, + t._CableClubNPCPleaseComeAgainText or "Please come\nagain!", onDone)) + return + end + Game:writeSave() + require("src.core.Sound").play(Game.data, "Save") + local ok, LinkState = pcall(require, "src.link.LinkState") + if ok and LinkState then + Game.stack:push(LinkState.new(Game)) + end + if onDone then onDone() end + end })) +end + +-- ------------------------------------------------------------------------- +-- trainers +-- ------------------------------------------------------------------------- + +function OverworldState:trainerDefeated(npc) + if Game.save.defeatedTrainers[npc.id] then return true end + local header = Game.data:trainerHeader(self.map.def.label, npc.def.index) + if header and header.event and Game.save.flags[header.event] then + return true + end + return false +end + +-- Run the pre-battle text -> battle -> won text -> flags sequence. +function OverworldState:engageTrainer(npc, onDone) + local d = npc.def + local header = Game.data:trainerHeader(self.map.def.label, d.index) + local battleText = header and header.battle and Game.data.text[header.battle] + if not battleText then + battleText = select(1, Game.data:resolveText(self.map.def.label, d.text)) + or "I like shorts!\nThey're comfy and\neasy to wear!" + end + local wonText = header and header.won and Game.data.text[header.won] + + local BattleState = require("src.battle.BattleState") + Game.stack:push(TextBox.new(Game, battleText, function() + local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + battle.onFinish = function(result) + if result == "win" then + Game.save.defeatedTrainers[npc.id] = true + if header and header.event then + Game.save.flags[header.event] = true + end + self:checkVictoryRewards(d.trainerClass, d.trainerParty) + local after = function() + self:afterBattle(result) + if onDone then onDone() end + end + if wonText then + Game.stack:push(TextBox.new(Game, wonText, after)) + else + after() + end + else + self:afterBattle(result) + if onDone then onDone() end + end + end + self:pushBattle(battle) + end)) +end + +-- Badges/items awarded after specific battles (data/scripts/victories.lua). +function OverworldState:checkVictoryRewards(trainerClass, partyIndex) + local victories = require("data.scripts.victories") + local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)] + if not reward then return self:runVictoryHook() end + if reward.flag then + if Game.save.flags[reward.flag] then return self:runVictoryHook() end + Game.save.flags[reward.flag] = true + end + local lines = {} + if reward.badge then + Game.save.inventory[reward.badge] = 1 + local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name + or reward.badge + table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name)) + end + if reward.item then + local inv = Game.save.inventory + inv[reward.item] = (inv[reward.item] or 0) + 1 + local name = Game.data.items[reward.item] and Game.data.items[reward.item].name + or reward.item + table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + end + if #lines > 0 then + Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) + end + self:runVictoryHook() +end + +-- pokered reloads the map after every battle, re-running the map +-- script (e.g. LoreleiShowOrHideExitBlock); this hook is the port's +-- equivalent so seals/toggles refresh without leaving the map +function OverworldState:runVictoryHook() + local hooks = mapScripts.get(self.map.id) + if hooks and hooks.onVictory then hooks.onVictory(Game, self) end +end + +-- STAY trainers with a facing spot the player crossing their line of +-- sight (range from the extracted trainer headers), walk up and battle. +function OverworldState:checkTrainerSight() + if self.player.moving or self.engaging then return end + if Game.stack:top() ~= self then return end + local p = self.player + for _, npc in ipairs(self.npcs) do + local d = npc.def + -- CheckFightingMapTrainers engages ANY aligned trainer sprite, + -- walkers included (they sight between steps) + if d.trainerClass and not npc.moving + and not self:trainerDefeated(npc) + and not mapScripts.talkScript(self.map.id, d.text) then + local header = Game.data:trainerHeader(self.map.def.label, d.index) + local range = header and header.range or 0 + local vec = DIRVEC[npc.facing] + if range > 0 and vec then + local dist + if vec[1] ~= 0 and npc.cellY == p.cellY then + dist = (p.cellX - npc.cellX) * vec[1] + elseif vec[2] ~= 0 and npc.cellX == p.cellX then + dist = (p.cellY - npc.cellY) * vec[2] + end + -- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen + -- coordinates only (home/trainers.asm, engine/overworld/ + -- trainer_sight.asm) -- there is no line-of-sight obstruction check. + -- An aligned trainer within range engages through interposed NPCs and + -- unwalkable tiles, and the scripted walk-up below (scriptMove) also + -- ignores collision, so the trainer simply walks/overlaps through + -- anything on the line -- exactly as OAM sprites overlap on hardware. + if dist and dist >= 1 and dist <= range then + self:startTrainerApproach(npc, dist) + return + end + end + end + end +end + +-- data/trainers/encounter_types.asm +local FEMALE_TRAINERS = { + OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true, + OPP_COOLTRAINER_F = true, +} +local EVIL_TRAINERS = { + OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true, + OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true, + OPP_GIOVANNI = true, OPP_ROCKET = true, +} + +function OverworldState:startTrainerApproach(npc, dist) + self.engaging = true + npc.frozen = true + -- the encounter sting (PlayTrainerMusic): evil / female / male by + -- class; rivals and gym leaders keep their own music + local cls = npc.def.trainerClass + if cls and not cls:find("RIVAL") then + local theme = EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer" + or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer" + or "Music_MeetMaleTrainer" + require("src.core.Music").play(Game.data, theme) + end + local function fight() + self:engageTrainer(npc, function() + npc.frozen = false + self.engaging = false + end) + end + -- the "!" bubble pause before the walk-up (EmotionBubble holds the + -- world for 60 frames, engine/overworld/emotion_bubbles.asm) + self.emote = { + npc = npc, frames = 60, + onDone = function() + if dist > 1 then + self:scriptMove(npc, npc.facing, dist - 1, fight) + else + fight() + end + end, + } +end + +-- Dispatch a TEXT_* constant: hand-ported script first, then extracted text. +function OverworldState:showMapText(textConst, npc, onDone) + local mapLabel = self.map.def.label + local script = mapScripts.talkScript(self.map.id, textConst) + if script then + if npc then npc:facePlayer(self.player) end + if type(script) == "function" then + -- Lua talk handlers for logic that doesn't fit command rows + script(Game, self, npc, onDone or function() end) + return + end + self.runner:run(script, { npc = npc, onDone = onDone }) + return + end + local text, needsAsm = Game.data:resolveText(mapLabel, textConst) + if text then + if needsAsm then + Logger.warn("%s/%s uses text_asm; showing plain text (port a script in data/scripts/)", + mapLabel, textConst) + end + if npc then npc:facePlayer(self.player) end + Game.stack:push(TextBox.new(Game, text, onDone)) + else + Logger.warn("no text for %s/%s", mapLabel, textConst) + if onDone then onDone() end + end +end + +-- ------------------------------------------------------------------------- +-- step events +-- Field poison (engine/events/poison.asm ApplyOutOfBattlePoisonDamage): +-- every 4th step, 1 HP per poisoned mon; the BG flickers dark with +-- SFX_POISONED; fainted mons get their message; a whole-party faint +-- blacks out like a lost battle. Returns true when the step should +-- stop (a text box is up). +function OverworldState:applyFieldPoison() + local save = Game.save + save.poisonSteps = ((save.poisonSteps or 0) + 1) % 4 + if save.poisonSteps ~= 0 then return false end + local anyPoisoned, fainted = false, {} + for _, mon in ipairs(save.party) do + if mon.status == "PSN" and mon.hp > 0 then + anyPoisoned = true + mon.hp = mon.hp - 1 + if mon.hp <= 0 then + mon.hp = 0 + mon.status = nil -- the original clears status on the faint + table.insert(fainted, mon) + end + end + end + if not anyPoisoned then return false end + require("src.core.Sound").play(Game.data, "Poisoned") + self.poisonFlash = 12 + local queue = {} + for _, mon in ipairs(fainted) do + local name = mon.nickname or Game.data.pokemon[mon.species].name + table.insert(queue, ("%s\nfainted!"):format(name)) + end + local alive = false + for _, mon in ipairs(save.party) do + if mon.hp > 0 then alive = true break end + end + local function showNext() + local msg = table.remove(queue, 1) + if msg then + Game.stack:push(TextBox.new(Game, msg, showNext)) + return + end + if not alive then + Game.stack:push(TextBox.new(Game, + ("%s blacked\nout!"):format(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 / 2) + self:warpToHealPoint() + end)) + end + end + if #queue > 0 or not alive then + showNext() + return true + end + return false +end + +-- ------------------------------------------------------------------------- + +function OverworldState:onStepComplete() + local p = self.player + + -- dismounting a surf: landing on a walkable cell ends it + if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then + p.surfing = false + require("src.core.Music").setSurfing(Game.data, false) + end + + -- Route 22 Gate rewrites LAST_MAP by Y before warps/guards fire + self:syncRoute22GateLastMap() + + -- hand-ported step triggers (Pallet intro, Saffron gate guards, ...) + local hooks = mapScripts.get(self.map.id) + if hooks and hooks.onStep then + if hooks.onStep(Game, self, p.cellX, p.cellY) then + return + end + end + + -- spinner arrow tiles (Viridian Gym, Rocket Hideout) + if self:checkSpinner() then return end + + -- badge-check guards (Route 22 gate / Route 23) + if self:checkBadgeGate() then return end + + -- forced bike/surf tiles + the Seafoam surf currents + if self:checkForcedMovement() then return end + if self:checkSeafoamCurrent() then return end + + -- the Safari game step counter (engine/events/hidden_events/safari_game.asm) + if self:safariStep() then return end + + -- day-care: the boarded Pokémon gains 1 exp per step (like the original) + if Game.save.daycare and Game.save.daycare.mon then + Game.save.daycare.steps = (Game.save.daycare.steps or 0) + 1 + end + + -- out-of-battle poison (engine/events/poison.asm): every 4th step + -- each poisoned mon loses 1 HP, with the screen flicker + sound + if self:applyFieldPoison() then return end + + self.boulderTried = nil -- a completed step ends any armed boulder push + + -- arriving on a door/warp tile warp; a non-door warp square also fires + -- when the extra check passes and the d-pad is held + -- (CheckWarpsNoCollision) + if self.justWarped then + self.justWarped = false + else + local w = Warp.onArrive(self.map, p.cellX, p.cellY) + if not w and self:dirHeld() then + w = Warp.onCollision(self.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, p.facing) + end + if w then + self:takeWarp(w.def) + return + end + end + + if Game.save.repelSteps and Game.save.repelSteps > 0 then + Game.save.repelSteps = Game.save.repelSteps - 1 + if Game.save.repelSteps == 0 then + -- no encounter on the exact wear-off step (wild_encounters.asm + -- .lastRepelStep returns CantEncounter) + Game.stack:push(TextBox.new(Game, "REPEL's effect\nwore off.")) + return + end + end + + -- wild encounters in grass, on water while surfing, or -- on indoor + -- maps whose tileset is not FOREST -- on EVERY tile + -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) + local encDef = Game.data.encounters[self.map.id] + local enc + local indoor = Game.data.field.indoorEncounters + if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then + enc = Encounter.roll({ grass = encDef.water }) + elseif self.map:isGrassCell(p.cellX, p.cellY) then + enc = Encounter.roll(encDef) + elseif indoor and self.map.def.index >= indoor.firstIndoorMap + and self.map.def.tileset ~= indoor.excludedTileset then + enc = Encounter.roll(encDef) + end + if enc then + -- REPEL blocks wild mons weaker than the lead + local lead = Game.save.party[1] + if Game.save.repelSteps and Game.save.repelSteps > 0 + and lead and enc.level < lead.level then + return + end + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(Game, enc.species, enc.level) + -- Pokémon Tower ghosts are unidentifiable without the Silph Scope + if self.map.id:find("POKEMON_TOWER", 1, true) == 1 + and not Game.save.inventory.SILPH_SCOPE then + battle:makeGhost() + end + -- Safari game encounters use the BALL/BAIT/ROCK/RUN menu + if Game.save.safari and self.map.id:find("SAFARI_ZONE", 1, true) == 1 then + battle:makeSafari(Game.save.safari) + end + battle.onFinish = function(result) self:afterBattle(result) end + self:pushBattle(battle) + return + end +end + +-- Spinner arrow tiles (scripts/{ViridianGym,RocketHideoutB2F,B3F}.asm +-- via field.spinners): landing on one plays the arrow SFX and slides the +-- player along the extracted movement list; the landing cell may be +-- another arrow, which chains. +function OverworldState:checkSpinner() + local list = Game.data.field.spinners and Game.data.field.spinners[self.map.id] + if not list then return false end + local p = self.player + for _, sp in ipairs(list) do + if sp.x == p.cellX and sp.y == p.cellY then + require("src.core.Sound").play(Game.data, "Arrow_Tiles") + self:runSpinnerMoves(sp.moves, 1) + return true + end + end + return false +end + +function OverworldState:runSpinnerMoves(moves, i) + local mv = moves[i] + if not mv then + self.player.spinning = false + if not self:checkSpinner() and self.player.surfing then + self:checkSeafoamCurrent() + end + return + end + self.player.spinning = true -- spin the sprite while sliding + self:scriptMove(self.player, mv.dir, mv.count, function() + self:runSpinnerMoves(moves, i + 1) + end) +end + +-- Badge-check guards (scripts/Route22Gate.asm, scripts/Route23.asm via +-- field.badgeGates): stepping on a guard row without the badge turns +-- you back; with it, the guard waves you through once. + +-- pokered Route22Gate_Script: every frame, Y < 4 -> wLastMap = ROUTE_23, +-- else ROUTE_22. All four gate door warps are LAST_MAP, so this is what +-- makes the north exit leave onto Route 23 (and the south onto Route 22). +function OverworldState.route22GateOutdoor(cellY) + return cellY < 4 and "ROUTE_23" or "ROUTE_22" +end + +function OverworldState:syncRoute22GateLastMap() + if not self.map or self.map.id ~= "ROUTE_22_GATE" then return end + local id = OverworldState.route22GateOutdoor(self.player.cellY) + if self.lastOutdoor and self.lastOutdoor.id == id then return end + local warps = Game.data.maps[id] and Game.data.maps[id].warps + local w = warps and warps[1] + self:rememberOutdoor(id, w and w.x or 0, w and w.y or 0) +end + +function OverworldState:checkBadgeGate() + local gates = Game.data.field.badgeGates + if not gates then return false end + local p = self.player + local t = Game.data.text + + if self.map.id == "ROUTE_22_GATE" then + local g = gates.ROUTE_22_GATE + for _, c in ipairs(g.coords) do + if p.cellX == c.x and p.cellY == c.y then + if Game.save.inventory[g.badge] then + if not Game.save.flags.PASSED_ROUTE22_GATE then + Game.save.flags.PASSED_ROUTE22_GATE = true + -- Route22GateGuardGoRightAheadText plays sound_get_item_1 + require("src.core.Sound").play(Game.data, "Get_Item1") + Game.stack:push(TextBox.new(Game, + t["_" .. g.passText] or "Go right ahead!")) + end + return false + end + -- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED + require("src.core.Sound").play(Game.data, "Denied") + Game.stack:push(TextBox.new(Game, + (t["_" .. g.failText] or "You don't have the\nBOULDERBADGE yet!") + .. (t._Route22GateGuardICantLetYouPassText or ""), function() + self:scriptMove(p, "down", 1) + end)) + return true + end + end + return false + end + + if self.map.id == "ROUTE_23" then + for _, g in ipairs(gates.ROUTE_23.guards) do + if p.cellY == g.y and (not g.maxX or p.cellX <= g.maxX) + and not Game.save.flags[g.event] then + local badgeName = Game.data.items[g.badge] and Game.data.items[g.badge].name + or g.badge + if Game.save.inventory[g.badge] then + Game.save.flags[g.event] = true + -- Route23OhThatIsTheBadgeText plays sound_get_item_1 + require("src.core.Sound").play(Game.data, "Get_Item1") + local text = (t["_" .. gates.ROUTE_23.passText] or + "Oh! That is the\n{RAM}!"):gsub("{RAM:wNameBuffer}", badgeName) + Game.stack:push(TextBox.new(Game, text)) + return false + end + -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED + require("src.core.Sound").play(Game.data, "Denied") + local text = (t["_" .. gates.ROUTE_23.failText] or + "You don't have the\n{RAM} yet!"):gsub("{RAM:wNameBuffer}", badgeName) + Game.stack:push(TextBox.new(Game, text, function() + self:scriptMove(p, "down", 1) + end)) + return true + end + end + return false + end + return false +end + +-- Forced bike/surf tiles (data/maps/force_bike_surf.asm): the Cycling +-- Road entrances force you onto the BICYCLE (or turn you back without +-- one); the Seafoam current mouths force surfing. +function OverworldState:checkForcedMovement() + local fm = Game.data.field.forcedMovement + if not fm then return false end + local p = self.player + for _, tile in ipairs(fm.tiles[self.map.id] or {}) do + if p.cellX == tile.x and p.cellY == tile.y then + if tile.mode == "bike" then + -- CheckForceBikeOrSurf (engine/overworld/player_state.asm) also + -- sets BIT_ALWAYS_ON_BIKE of wStatusFlags6 here -- the flag + -- IsSurfingAllowed reads to refuse SURF on the Cycling Road. + -- Cleared by the Route 16/18 gate scripts, fly/dungeon warps and + -- blackouts (see setMap / flyTo / warpToHealPoint). + if Game.save.onBike then + Game.save.forcedBike = true + return false + end + if (Game.save.inventory.BICYCLE or 0) > 0 then + -- CheckForceBikeOrSurf mounts silently; _CyclingIsFunText only + -- exists as IsSurfingAllowed's refusal (engine/overworld/ + -- field_move_messages.asm), never as a mount message. + Game.save.onBike = true + Game.save.forcedBike = true + require("src.core.Music").playMap(Game.data, self.map.id, true) + else + Game.stack:push(TextBox.new(Game, "You need a\nBICYCLE for the\nCycling Road!", + function() + local back = ({ up = "down", down = "up", + left = "right", right = "left" })[p.facing] + self:scriptMove(p, back, 1) + end)) + return true + end + elseif tile.mode == "surf" then + p.surfing = true + require("src.core.Music").setSurfing(Game.data, true) + end + return false + end + end + return false +end + +-- The Seafoam Islands surf currents (scripts/SeafoamIslandsB3F/B4F.asm +-- via field.seafoam): while the plug boulders aren't down, the water +-- drags the player along the extracted movement lists; the B4F pool +-- edge pushes you back up until the B3F boulders fall. +function OverworldState:checkSeafoamCurrent() + local sf = Game.data.field.seafoam and Game.data.field.seafoam[self.map.id] + if not sf then return false end + local p = self.player + local function allSet(events) + for _, e in ipairs(events or {}) do + if not Game.save.flags[e] then return false end + end + return true + end + + if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then + for _, c in ipairs(sf.forcedExit.coords) do + if p.cellX == c.x and p.cellY == c.y then + require("src.core.Sound").play(Game.data, "Collision") + self:scriptMove(p, "up", c.y == 17 and 2 or 1) + return true + end + end + end + + if not p.surfing then return false end + local active = {} + if not allSet(sf.currentsDisabledByEvents) then + for _, c in ipairs(sf.currents or {}) do table.insert(active, c) end + end + if sf.entryCurrent then + local plugged = true + for _, h in ipairs((sf.pluggedByHolesOn or {}).holes or {}) do + if not Game.save.flags[h.boulderEvent] then plugged = false end + end + if not plugged then table.insert(active, sf.entryCurrent) end + end + for _, c in ipairs(active) do + if p.cellX == c.x and p.cellY == c.y then + self:runSpinnerMoves(c.moves, 1) + return true + end + end + return false +end + +-- Boulder holes (Seafoam4HolesCoords etc.): a boulder pushed onto a +-- hole falls to the floor below, permanently plugging a current. +function OverworldState:seafoamHolesFor(mapId) + local out = {} + for owner, sf in pairs(Game.data.field.seafoam or {}) do + if owner == mapId then + for _, h in ipairs(sf.holes or {}) do + table.insert(out, { hole = h, destMap = sf.holeDestination }) + end + end + if sf.pluggedByHolesOn and sf.pluggedByHolesOn.map == mapId then + for _, h in ipairs(sf.pluggedByHolesOn.holes or {}) do + table.insert(out, { hole = h, destMap = owner }) + end + end + end + return out +end + +-- toggleable_objects.asm names (TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_1) +-- vs object_event const names (SEAFOAMISLANDSB3F_BOULDER1) +local function toggleToObjectName(mapId, toggleName) + local prefix = "TOGGLE_" .. mapId .. "_" + if toggleName:sub(1, #prefix) ~= prefix then return nil end + return mapId:gsub("_", "") .. "_" .. toggleName:sub(#prefix + 1):gsub("_", "") +end + +function OverworldState:boulderIntoHole(npc) + for _, entry in ipairs(self:seafoamHolesFor(self.map.id)) do + local h = entry.hole + if npc.cellX == h.x and npc.cellY == h.y then + require("src.core.Sound").play(Game.data, "Faint_Thud") + Game.save.flags[h.boulderEvent] = true + local toggles = Game.save.objectToggles or {} + Game.save.objectToggles = toggles + if h.hideObject then + local name = toggleToObjectName(self.map.id, h.hideObject) + if name then + toggles[self.map.id] = toggles[self.map.id] or {} + toggles[self.map.id][name] = false + end + end + if h.showObject and entry.destMap then + local name = toggleToObjectName(entry.destMap, h.showObject) + if name then + toggles[entry.destMap] = toggles[entry.destMap] or {} + toggles[entry.destMap][name] = true + end + end + for i = #self.npcs, 1, -1 do + if self.npcs[i] == npc then table.remove(self.npcs, i) end + end + for i = #self.entities, 1, -1 do + if self.entities[i] == npc then table.remove(self.entities, i) end + end + Game.stack:push(TextBox.new(Game, "The boulder fell\nthrough the hole!")) + return true + end + end + return false +end + +-- Safari game step/ball bookkeeping. 502 steps per ¥500 game; running +-- out of steps (or balls, checked after battles) ends the game and +-- returns to the gate (engine/events/hidden_events/safari_game.asm). +-- The oracle gates on EVENT_IN_SAFARI_ZONE, not the current map (see +-- home/overworld.asm:307-310); that flag is set right before the +-- entrance auto-walk off SAFARI_ZONE_GATE and cleared only when the +-- player returns to the gate (or uses an Escape Rope), so every +-- interior Safari Zone map -- the 4 zone quadrants plus the 4 rest +-- houses plus the secret house -- counts, and the gate itself never +-- does. +local SAFARI_STEP_MAPS = { + SAFARI_ZONE_CENTER = true, SAFARI_ZONE_EAST = true, + SAFARI_ZONE_NORTH = true, SAFARI_ZONE_WEST = true, + SAFARI_ZONE_CENTER_REST_HOUSE = true, SAFARI_ZONE_EAST_REST_HOUSE = true, + SAFARI_ZONE_NORTH_REST_HOUSE = true, SAFARI_ZONE_WEST_REST_HOUSE = true, + SAFARI_ZONE_SECRET_HOUSE = true, +} + +function OverworldState:safariStep() + local st = Game.save.safari + if not st or not SAFARI_STEP_MAPS[self.map.id] then return false end + st.steps = st.steps - 1 + if st.steps > 0 then return false end + self:safariGameOver("PA: Ding-dong!\nTime's up!") + return true +end + +function OverworldState:safariGameOver(text) + require("src.core.Sound").play(Game.data, "Safari_Zone_PA") + Game.save.safari = nil + local t = Game.data.text + Game.stack:push(TextBox.new(Game, + (text or "") .. "\f" .. (t._GameOverText or "PA: Your SAFARI\nGAME is over!"), + function() + self:startWarpTo("SAFARI_ZONE_GATE", 4, 3, "down") + end)) +end + +-- Blackouts return to the last heal point; evolutions run after battles. +function OverworldState:afterBattle(result) + local lead = Game.save.party[1] + Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), + lead and lead.species or "-", lead and lead.hp or 0, + lead and lead.stats.hp or 0) + local Evolution = require("src.pokemon.Evolution") + local function evolutions() + Evolution.checkParty(Game) + end + if result == "lose" then + -- blackout: revive the party at the last heal point; half the + -- money is lost (like the original) + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(Game.save.party) do + Pokemon.heal(mon) + end + Game.save.money = math.floor(Game.save.money / 2) + self:warpToHealPoint(evolutions) + else + -- throwing the last SAFARI BALL ends the game + if Game.save.safari and Game.save.safari.balls <= 0 then + self:safariGameOver("PA: You're out of\nSAFARI BALLs!") + end + evolutions() + end +end + +-- ------------------------------------------------------------------------- +-- warps +-- ------------------------------------------------------------------------- + +function OverworldState:takeWarp(warpDef) + local last = self.lastOutdoor + if warpDef.destMap == "LAST_MAP" and not last then + -- old saves / unexpected states: never crash on an exit mat, fall + -- back to the heal point's town door (or Pallet) + Logger.warn("LAST_MAP warp with no remembered outdoor map; using heal point") + local heal = Game.save.lastHeal + last = heal and heal.outdoor or { id = "PALLET_TOWN", x = 5, y = 6 } + end + local destMap, x, y = Warp.destination(Game.data, warpDef, last) + -- facing carries across the warp (leaving a gate sideways keeps you + -- walking sideways; house exit mats are stepped onto facing down) + local facing = self.player.facing + self.doorWarp = true -- door SFX + outdoor walk-out step + self:startWarpTo(destMap, x, y, facing) +end + +-- Remember the outdoor side for LAST_MAP exits (pokered's wLastMap). +function OverworldState:rememberOutdoor(id, x, y) + self.lastOutdoor = { id = id, x = x, y = y } + Game.save.lastOutdoor = self.lastOutdoor +end + +-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT). +-- The heal point is usually an interior, so LAST_MAP exits are re-pointed +-- at its remembered town door rather than wherever the player left from. +function OverworldState:warpToHealPoint(onDone) + local heal = Game.save.lastHeal or { map = "PALLET_TOWN", x = 5, y = 6 } + self.player.surfing = false + -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear + -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) + Game.save.forcedBike = nil + -- rematerializing plays the teleport-in poof (EnterMapAnim in + -- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then + -- ENTER_2 after the spin-down); blackouts take this path too + self.arriveWarp = "teleport" + self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone) + if heal.outdoor then + self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) + end +end + +-- opts.keepMusic: scripted warps mid-cutscene keep the current song +-- playing across the map change, like BIT_NO_MAP_MUSIC (wStatusFlags7) +-- does for the Oak escort (engine/overworld/auto_movement.asm +-- PalletMovementScript_OakMoveLeft sets it; scripts/OaksLab.asm +-- OaksLabFollowedOakScript clears it and calls PlayDefaultMusic). +function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) + -- ANY transition off an outdoor map remembers the outdoor side, so + -- scripted warps (the Oak walk-in) keep LAST_MAP exits working. + -- CheckIfInOutsideMap (home/overworld.asm) treats PLATEAU (Route 23 / + -- Indigo Plateau) as outside too, alongside OVERWORLD -- without it, + -- LAST_MAP exits taken off Route 23/Indigo Plateau (the Route 22 Gate + -- back door, the Indigo Plateau lobby doors) resolve against a stale + -- remembered map instead. + local outsideTilesets = { OVERWORLD = true, PLATEAU = true } + if outsideTilesets[self.map.def.tileset] and mapId ~= self.map.id then + self:rememberOutdoor(self.map.id, self.player.cellX, self.player.cellY) + end + self.transitioning = true + local doorWarp = self.doorWarp + self.doorWarp = nil + local arriveWarp = self.arriveWarp + self.arriveWarp = nil + Game.stack:push(Transition.new(Game, function() + self:setMap(mapId, x, y, facing or "down", opts) + self.justWarped = true + -- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player + -- back in (player_animations.asm EnterMapAnim); ordinary door + -- warps never take this branch + if arriveWarp == "fly" then + require("src.core.Sound").play(Game.data, "Fly") + elseif arriveWarp == "teleport" then + require("src.core.Sound").play(Game.data, "Teleport_Enter1") + -- ENTER_2 caps the spin-down a moment later + self.delaySfx = { frames = 40, key = "Teleport_Enter2" } + end + if doorWarp then + local outdoor = self.map.def.tileset == "OVERWORLD" + require("src.core.Sound").play(Game.data, + outdoor and "Go_Outside" or "Go_Inside") + -- stepping out of an outdoor door mat (the original's walk-out) + if outdoor and self.player.facing == "down" + and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then + self:scriptMove(self.player, "down", 1) + end + end + end, function() + self.transitioning = false + if onDone then onDone() end + end)) +end + +-- Replace a map block (Victory Road barriers, Cut trees) and redraw. +function OverworldState:replaceBlock(bx, by, block) + self.map:setBlock(bx, by, block) + self.map.renderer:rebuild() +end + +-- ------------------------------------------------------------------------- +-- scripted movement +-- ------------------------------------------------------------------------- + +function OverworldState:scriptMove(entity, dir, tiles, onDone) + table.insert(self.scriptMoves, { + entity = entity, dir = dir, remaining = tiles, onDone = onDone, + }) +end + +-- A step-in-place beat: the entity plays one walk-cycle animation (16 +-- frames) without translating, keeping its current facing. Ports the +-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm +-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching +-- on the lab door mat at the tail of RLEList_ProfOakWalkToLab. +function OverworldState:marchInPlace(entity, onDone) + table.insert(self.scriptMoves, { + entity = entity, inPlace = true, remaining = 1, onDone = onDone, + }) +end + +-- Advance scripted moves in two phases so a chained step (a new move +-- queued by a completing move's onDone) begins the SAME frame the +-- previous one ends -- back-to-back 16-frame tiles like the GB's +-- simulated-joypad / NPC scripted movement, with no idle frame between +-- tiles. Phase 1 retires finished moves (which may chain new ones); +-- phase 2 then starts every not-yet-moving move. +function OverworldState:updateScriptMoves() + local i = 1 + while i <= #self.scriptMoves do + local mv = self.scriptMoves[i] + if not mv.entity.moving and mv.remaining <= 0 then + table.remove(self.scriptMoves, i) + if mv.onDone then mv.onDone() end + -- don't advance i: a move chained by onDone may now sit at i + else + i = i + 1 + end + end + for _, mv in ipairs(self.scriptMoves) do + local e = mv.entity + if not e.moving and mv.remaining > 0 then + if mv.inPlace then + e.moving = true + e.marching = true + e.progress = 0 + else + e.facing = mv.dir + local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) + e.targetX, e.targetY = tx, ty + e.moving = true + e.progress = 0 + end + mv.remaining = mv.remaining - 1 + end + end +end + +-- ------------------------------------------------------------------------- +-- draw / save +-- ------------------------------------------------------------------------- + +function OverworldState:draw() + Game.renderer:beginWorldPass() + self:drawWorld() + Game.renderer:endWorldPass() + self:drawUI() +end + +-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under. +-- World zones are rectangles in flat world-canvas space (the current map's +-- base fills the view; neighbour maps stack on top), so the last zone that +-- contains the foot wins -- the same later-zone-on-top priority the flat +-- blit's scissoring gives. nil when there are no zones (headless / stale +-- palettes), which leaves the billboard uncolorized. +local function zoneColorsAt(zones, fx, fy) + if not zones then return nil end + for i = #zones, 1, -1 do + local z = zones[i] + if fx >= z.x and fx < z.x + z.w and fy >= z.y and fy < z.y + z.h then + return z.colors + end + end + return zones[1] and zones[1].colors or nil +end + +-- Draw a standing thing as an upright billboard (tilt mode only). ONLY the +-- ground tilts: a standing thing draws UPRIGHT and UNSCALED -- pixel-identical +-- to flat mode (same crisp nearest-neighbour art, nothing sheared, resized or +-- clipped). The single thing tilt changes about it is its on-screen anchor: +-- its foot (fx, fy -- the baseline centre of its cell, in world-canvas +-- pixels) moves to where that ground point projects, Tilt.groundPoint(fx,fy). +-- depthScale is deliberately ignored for sizing. `colors` is the SGB palette +-- of the map the foot stands on: the flat path colorizes the whole world +-- canvas at blit time, but the upright canvas composites with no zone pass, +-- so each billboard carries its own colorization here. `keyed` selects the +-- color-0-keyed palette variant (tall-grass feet overdraw, which must show the +-- sprite through the tile's white gaps) over the plain one (sprites, FX +-- overlays). drawFn issues the actual draws in flat world-canvas coordinates; +-- the transform just slides them from the flat foot onto the projected anchor. +function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn) + local sx, sy = Tilt.groundPoint(fx, fy, vw, vh) + local shader = colors and (keyed and PaletteFX.keyedShader() + or PaletteFX.shader()) or nil + if shader then + PaletteFX.sendColors(shader, colors) + love.graphics.setShader(shader) + end + love.graphics.push() + love.graphics.translate(sx - fx, sy - fy) + drawFn() + love.graphics.pop() + if shader then love.graphics.setShader() end +end + +function OverworldState:drawWorld() + -- advance the water/flower tile animation (runs under dialogs too) + require("src.render.TileRenderer").tick() + -- let the renderer know whether a spinner puzzle is currently sliding + -- the player, so it can flicker the arrow tiles between the blur and + -- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles) + require("src.render.TileRenderer").setSpinning(self.player.spinning) + local cam = self.camera + -- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes + -- hSCY, which scrolls the BG layer only -- tiles bounce while OAM + -- sprites stay put. ElevatorShake drives bgShakeY; zero elsewhere. + local bgY = cam.y + (self.bgShakeY or 0) + -- border block tiled behind everything the ring doesn't reach + local vw, vh = Game.renderer:worldViewSize() + -- Only things that actually stand (player, NPCs, ghosts, items and the FX + -- attached to them) leave the ground canvas to billboard upright in a + -- separate pass anchored to the projected ground (:billboard). Everything + -- else -- map tiles, which includes buildings/trees/fences/signs, since in + -- Gen 1 those are background tiles rather than sprites -- draws into the + -- one ground canvas exactly as in flat mode and tilts with it as a single + -- rigid plane (Renderer projects that whole canvas through the mesh when + -- tilt is active). So the ground draw calls below never change with tilt; + -- only the sprite/FX draw path below them branches. The sorts below only + -- reorder (no draws), so they run once for both paths. + local tilt = Tilt.active() + self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) + self.map.renderer:draw(cam.x, bgY) + for _, nb in ipairs(self.neighbors) do + nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy) + end + -- per-billboard SGB palette source; only needed (and only paid for) when + -- tilting. nil headless / on stale palettes -> billboards go uncolorized. + local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil + + -- ghost NPCs on neighbor maps, y-sorted among themselves + table.sort(self.ghosts, + function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end) + table.sort(self.entities, function(a, b) return a.py < b.py end) + + -- === shared FX draw bodies ========================================== + -- Each draws at flat world-canvas offsets; the tilt path wraps the + -- standing ones in an upright billboard, the flat path calls them inline + -- in their historical order. (Bodies are byte-identical to the pre-tilt + -- inline code, so the flat draw sequence is unchanged.) + + -- the Pokémon Center heal machine (PokeCenterOAMData): the monitor + -- tile over the machine's screen and one ball per healed mon in two + -- mirrored columns, all blinking during the jingle flash. The GB + -- draws it at fixed screen coords with the player's cell BG-aligned + -- at (64,64); anchoring those coords to where the player stood keeps + -- the overlay on the machine at any zoom. + local function fxHeal() + if not (self.healAnim and self.healAnim.visible) then return end + local ha = self.healAnim + local fxDef = Game.data.field.overworldFx + if self.healMachineImg == nil and fxDef and fxDef.healMachine then + local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) + self.healMachineImg = ok and img or false + end + local img = self.healMachineImg + if img then + if not self.healMachineQuads then + local w, h = img:getWidth(), img:getHeight() + self.healMachineQuads = { + love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) + love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) + } + end + local ox = ha.px - 64 - cam.x + local oy = ha.py - 64 - cam.y + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20) + for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do + local b = HEAL_BALL_XY[i] + if b[3] then -- right column: OAM_XFLIP + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], 0, -1, 1) + else + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2]) + end + end + end + end + + -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, + -- flickering (AnimateBoulderDust XORs the OBJ palette every step) + local function fxDust() + if not self.dustAnim then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if smoke then + if self.smokeImg == nil then + local ok, img = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and img or false + end + if self.smokeImg then + local da = self.dustAnim + local dx = da.x * 16 - cam.x + local dy = da.y * 16 - cam.y + local flicker = math.floor(da.frames / 4) % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) + end + end + love.graphics.setColor(1, 1, 1, 1) + end + end + end + + -- the "!" bubble above a trainer who spotted the player + local function fxEmote() + if not (self.emote and self.emote.npc) then return end + local npc = self.emote.npc + local ex = npc.px - cam.x + 4 + local ey = npc.py - cam.y - 14 + local bubble = Game.data.field.emotionBubbles + local drawn = false + if bubble and bubble.path then + local ok, img = pcall(function() + self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path) + return self.emoteImg + end) + -- EXCLAMATION_BUBBLE is index 0 -> first crop + local rect = bubble.bubbles and bubble.bubbles[1] + if ok and img and rect then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y, + rect.w, rect.h, img:getDimensions()), ex, ey) + drawn = true + end + end + if not drawn then + local Font = require("src.render.Font") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", ex, ey, 10, 12) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) + Font.draw("!", ex + 1, ey + 2) + love.graphics.setColor(1, 1, 1, 1) + end + end + + -- Rock Tunnel darkness: a small window of light around the player + -- until FLASH is used (the original darkens the palette instead); + -- fills the whole world view, so surveying doesn't peek past it + local function fxDark() + if not self.dark then return end + local px = self.player.px - cam.x + 8 + local py = self.player.py - cam.y + 8 + local r = 28 + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, vw, math.max(0, py - r)) + love.graphics.rectangle("fill", 0, py + r, vw, vh - (py + r)) + love.graphics.rectangle("fill", 0, py - r, math.max(0, px - r), r * 2) + love.graphics.rectangle("fill", px + r, py - r, vw - (px + r), r * 2) + love.graphics.setColor(1, 1, 1, 1) + end + + -- the FLY bird sweeping off with the player + local function fxBird() + if not self.flyAnim then return end + if not self.birdSprite and Game.data.sprites.SPRITE_BIRD then + local SR = require("src.render.SpriteRenderer") + self.birdSprite = SR.new(Game.data.sprites.SPRITE_BIRD) + end + if self.birdSprite then + local t = 48 - self.flyAnim.frames + local bx = self.player.px - t * 4 + local by = self.player.py - math.floor(t * 1.5) + love.graphics.setColor(1, 1, 1, 1) + self.birdSprite:draw(bx, by, cam.x, cam.y, "left", + math.floor(t / 4) % 2, false) + end + end + + -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) + local function fxRod() + if not self.fishing then return end + local fx = Game.data.field.overworldFx + local rod = fx and fx.fishingRod + if rod then + if self.rodImg == nil then + local ok, img = pcall(love.graphics.newImage, rod.path) + self.rodImg = ok and img or false + end + if self.rodImg then + local p = self.player + local vec = DIRVEC[self.fishing.facing] or DIRVEC.down + local rx = p.px - cam.x + 4 + vec[1] * 12 + local ry = p.py - cam.y + 4 + vec[2] * 12 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.rodImg, rx, ry) + end + end + end + + if not tilt then + -- === FLAT PATH: everything into the one world canvas, as before ===== + for _, g in ipairs(self.ghosts) do + g.npc:draw(cam.x - g.ox, cam.y - g.oy) + end + for _, e in ipairs(self.entities) do + if not (self.flyAnim and e == self.player) then + e:draw(cam.x, cam.y) + -- tall grass overdraws the sprite's feet (GB sprite priority); + -- the overdraw is BG tiles, so it rides the shake offset too + love.graphics.setColor(1, 1, 1, 1) + if self.map:isGrassCell(e.cellX, e.cellY) then + self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) + end + end + end + fxHeal() + fxDust() + fxEmote() + fxDark() + fxBird() + fxRod() + else + -- === TILT PATH: ground-hugging FX stay on the projected ground, all + -- standing things billboard upright over it in a separate pass. ====== + -- Dust is ground-hugging smoke -> ground canvas (puts it + -- with the flat layer, so it projects with the ground). Flat mode + -- draws it last, over the sprites, in the same canvas; here the two + -- layers are separate and composited ground-under-upright, so drawing + -- it now into the still-active ground canvas is order-equivalent. + fxDust() + + Game.renderer:beginUprightPass() + + -- One y-sorted list of ALL upright billboards -- sprites (player, NPCs, + -- ghosts) -- keyed on baseline world y (the foot / base row). Farther + -- rows project higher/smaller, so back-to-front is just ascending + -- baseline y. + local items = {} + for _, g in ipairs(self.ghosts) do + items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g } + end + for _, e in ipairs(self.entities) do + if not (self.flyAnim and e == self.player) then + items[#items + 1] = { y = e.py + 16, kind = "entity", e = e } + end + end + table.sort(items, function(a, b) return a.y < b.y end) + + for _, it in ipairs(items) do + if it.kind == "ghost" then + -- ghosts billboard just like real entities (foot offset folds in the + -- neighbour map's ox/oy that ghost draws already apply via the camera) + local g = it.g + local fx = g.npc.px - cam.x + g.ox + 8 + local fy = g.npc.py - cam.y + g.oy + 16 + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, + function() g.npc:draw(cam.x - g.ox, cam.y - g.oy) end) + else + local e = it.e + local fx = e.px - cam.x + 8 + local fy = e.py - cam.y + 16 + local colors = zoneColorsAt(zones, fx, fy) + self:billboard(fx, fy, vw, vh, colors, false, + function() e:draw(cam.x, cam.y) end) + -- tall-grass feet overdraw glued to the sprite: same anchor + depth + -- so it keeps hiding the feet, color-0-keyed palette so its white + -- gaps still show the sprite through (drawCellBottomRaw lets the + -- billboard own the shader; bgY keeps the elevator-shake offset). + if self.map:isGrassCell(e.cellX, e.cellY) then + self:billboard(fx, fy, vw, vh, colors, true, function() + love.graphics.setColor(1, 1, 1, 1) + self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY) + end) + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + self:billboard(fx, fy, vw, vh, colors, true, function() + love.graphics.setColor(1, 1, 1, 1) + self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY) + end) + end + end + end + + -- Screen-anchored world FX : each billboards at the + -- ground foot of the character it belongs to, so it stands upright and + -- scales with that character's depth. + -- heal machine -> the healed player's foot (the machine stands on + -- the ground in front of where the player was) + -- emote bubble -> the spotting NPC's foot (rides above its head) + -- fly bird, rod -> the player's foot + if self.healAnim and self.healAnim.visible then + local fx = self.healAnim.px - cam.x + 8 + local fy = self.healAnim.py - cam.y + 16 + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal) + end + if self.emote and self.emote.npc then + local fx = self.emote.npc.px - cam.x + 8 + local fy = self.emote.npc.py - cam.y + 16 + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote) + end + if self.flyAnim then + local fx = self.player.px - cam.x + 8 + local fy = self.player.py - cam.y + 16 + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird) + end + if self.fishing then + local fx = self.player.px - cam.x + 8 + local fy = self.player.py - cam.y + 16 + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) + end + + -- Rock Tunnel darkness is a screen-space light window, not a ground + -- object -- draw it flat into the upright canvas so it darkens the + -- final composited scene uniformly (the subtle tilt keeps the + -- projected player near the flat light centre). + fxDark() + + Game.renderer:endUprightPass() + end + +end + +-- screen-space overlays: drawn to the UI canvas at normal scale +function OverworldState:drawUI() + -- poison step flicker (ChangeBGPalColor0_4Frames: dark for two + -- 4-frame pulses) + if self.poisonFlash and self.poisonFlash > 0 then + self.poisonFlash = self.poisonFlash - 1 + local pulse = math.floor(self.poisonFlash / 4) % 2 == 1 + if pulse then + love.graphics.setColor(0, 0, 0, 0.45) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) + end + end +end + +function OverworldState:captureSave(save) + save.player.map = self.map.id + save.player.x = self.player.cellX + save.player.y = self.player.cellY + save.player.facing = self.player.facing +end + +return OverworldState diff --git a/src/world/Player.lua b/src/world/Player.lua new file mode 100644 index 00000000..9f225ded --- /dev/null +++ b/src/world/Player.lua @@ -0,0 +1,145 @@ +-- The player: tile-grid movement with pixel interpolation, faithful to the +-- original's feel: facing changes on a short tap, movement is tile-by-tile +-- at 1px per frame (16 frames per step), input locked while stepping. + +local Collision = require("src.world.Collision") +local SpriteRenderer = require("src.render.SpriteRenderer") + +local Player = {} +Player.__index = Player + +local STEP_FRAMES = 16 +-- a turn in place holds for the ~2 frames the original spends on the +-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress +-- returns to the loop without moving after a direction change) +local TURN_FRAMES = 2 + +function Player.new(data, cx, cy, facing) + local self = setmetatable({}, Player) + self.sprite = SpriteRenderer.new(data.sprites.SPRITE_RED) + -- the original surfs on the Seel sprite + -- (LoadSurfingPlayerSpriteGraphics, home/overworld.asm) + if data.sprites.SPRITE_SEEL then + self.surfSprite = SpriteRenderer.new(data.sprites.SPRITE_SEEL) + end + -- and cycles on the red_bike sheet (LoadPlayerSpriteGraphics) + if data.sprites.SPRITE_RED_BIKE then + self.bikeSprite = SpriteRenderer.new(data.sprites.SPRITE_RED_BIKE) + end + -- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png, + -- LedgeHoppingShadow, engine/overworld/ledges.asm) + local fx = data.field and data.field.overworldFx + if fx and fx.shadow then + local ok, img = pcall(love.graphics.newImage, fx.shadow.path) + self.shadowImg = ok and img or nil + end + self.cellX, self.cellY = cx, cy + self.px, self.py = cx * 16, cy * 16 + self.facing = facing or "down" + self.moving = false + self.progress = 0 + self.stepFlip = false + self.turnTimer = 0 + self.inputLocked = false + return self +end + +function Player:position() + return self.cellX, self.cellY +end + +-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil. +function Player:tryMove(dir, map, entities) + if self.moving or self.inputLocked then return nil end + if self.facing ~= dir then + self.facing = dir + self.turnTimer = TURN_FRAMES + return "turned" + end + if self.turnTimer > 0 then return nil end + local ok, why = Collision.canMove(map, entities, self, dir) + if not ok then + return "blocked", why + end + local tx, ty = Collision.target(self.cellX, self.cellY, dir) + self.targetX, self.targetY = tx, ty + self.moving = true + self.progress = 0 + -- the bicycle doubles walking speed (8 frames per step) + local save = require("src.core.Game").save + self.stepFramesCur = (save and save.onBike) and 8 or STEP_FRAMES + return "moved" +end + +-- Advance one fixed step; returns true when a step just completed. +function Player:update() + if self.turnTimer > 0 then + self.turnTimer = self.turnTimer - 1 + end + if not self.moving then return false end + local stepLen = self.stepFramesCur or STEP_FRAMES + self.progress = self.progress + 1 + local d = Collision.DELTA[self.facing] + local px = math.floor(self.progress * 16 / stepLen) + self.px = self.cellX * 16 + d[1] * px + self.py = self.cellY * 16 + d[2] * px + if self.progress >= stepLen then + self.cellX, self.cellY = self.targetX, self.targetY + self.targetX, self.targetY = nil, nil + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.moving = false + self.stepFlip = not self.stepFlip + return true + end + return false +end + +function Player:facingCell() + return Collision.target(self.cellX, self.cellY, self.facing) +end + +function Player:walkPhase() + if not self.moving then return 0 end + -- walk frame during the middle of the step + local p = self.progress % 16 + return (p >= 4 and p < 12) and 1 or 0 +end + +local SPIN_ORDER = { "down", "left", "up", "right" } + +function Player:draw(camX, camY) + local py = self.py + -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs + if self.hopFrames and self.hopFrames > 0 then + self.hopFrames = self.hopFrames - 1 + local t = 1 - self.hopFrames / (self.hopTotal or 32) + py = py - math.floor(10 * math.sin(t * math.pi) + 0.5) + -- the shadow stays on the ground under the jumper: one 8x8 tile + -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left + -- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM + + -- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm) + if self.shadowImg then + local sx = math.floor(self.px - camX) + local sy = math.floor(self.py - camY) - 4 + 8 + love.graphics.draw(self.shadowImg, sx, sy) + love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1) + love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1) + love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) + end + elseif self.surfing then + self.bobTimer = ((self.bobTimer or 0) + 1) % 32 + py = py + (self.bobTimer < 16 and 0 or 1) + end + local facing = self.facing + if self.spinning then + -- spinner tiles whirl the sprite (PlayerSpinningFacingOrder) + self.spinTimer = (self.spinTimer or 0) + 1 + facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1] + end + local sprite = (self.surfing and self.surfSprite) + or (self.onBike and self.bikeSprite) or self.sprite + sprite:draw(self.px, py, camX, camY, facing, + self:walkPhase(), self.stepFlip) +end + +return Player diff --git a/src/world/Warp.lua b/src/world/Warp.lua new file mode 100644 index 00000000..2902bc97 --- /dev/null +++ b/src/world/Warp.lua @@ -0,0 +1,111 @@ +-- Warp resolution. A warp fires when: +-- * the player finishes a step onto a warp cell whose collision tile is a +-- door tile or warp tile (stairs, doors, mats, cave entrances), or +-- * the player stands on a warp cell and tries to walk off the map edge +-- (exit carpets at the bottom of interiors), or +-- * the player stands on a warp cell and the "extra" check passes -- on +-- arrival with the d-pad held, or on a blocked step (route-gate +-- doorways, the Vermilion dock entrance, ...). +-- This mirrors pokered's CheckWarpsNoCollision / CheckWarpsCollision / +-- ExtraWarpCheck (home/overworld.asm). + +local Warp = {} + +-- Returns the warp entry to take when arriving at (cx,cy), or nil. +function Warp.onArrive(map, cx, cy) + local w = map:warpAtCell(cx, cy) + if w and map:isWarpTileCell(cx, cy) then + return w + end + return nil +end + +local function inList(list, v) + for _, x in ipairs(list) do + if x == v then return true end + end + return false +end + +-- ExtraWarpCheck: may the player standing at (cx,cy) facing dir warp +-- without a door/warp tile underfoot? On the carpet maps/tilesets the +-- tile in FRONT of the player must be a warp-carpet tile for the facing +-- direction (IsWarpTileInFrontOfPlayer; SS_ANNE_BOW tests one hardcoded +-- tile instead); everywhere else the player must face the map edge +-- (IsPlayerFacingEdgeOfMap). carpets = field.warpCarpets. +function Warp.extraCheck(map, carpets, cx, cy, dir) + local Collision = require("src.world.Collision") + local facingEdge = + (dir == "up" and cy == 0) + or (dir == "down" and cy == map.heightCells - 1) + or (dir == "left" and cx == 0) + or (dir == "right" and cx == map.widthCells - 1) + if not carpets then return facingEdge end + -- the map exceptions are tested before the tileset (ExtraWarpCheck) + local useCarpet + if inList(carpets.edgeMaps, map.id) then + useCarpet = false + elseif inList(carpets.function2Maps, map.id) then + useCarpet = true + else + useCarpet = inList(carpets.function2Tilesets, map.def.tileset) + end + if not useCarpet then return facingEdge end + local tx, ty = Collision.target(cx, cy, dir) + local front = map:cellTile(tx, ty) + if map.id == carpets.ssAnneBow.map then + return front == carpets.ssAnneBow.tile + end + return inList(carpets.tiles[dir], front) +end + +-- Returns the warp entry when standing on (cx,cy) and the extra check +-- passes toward dir (fired from a blocked step, or on arrival with the +-- d-pad held). +function Warp.onCollision(map, carpets, cx, cy, dir) + local w = map:warpAtCell(cx, cy) + if w and Warp.extraCheck(map, carpets, cx, cy, dir) then + return w + end + return nil +end + +-- Returns the warp entry when standing on (cx,cy) and moving toward dir +-- takes the player out of bounds. +function Warp.onEdge(map, cx, cy, dir) + local w = map:warpAtCell(cx, cy) + if not w then return nil end + local Collision = require("src.world.Collision") + local tx, ty = Collision.target(cx, cy, dir) + if not map:inBounds(tx, ty) then + return w + end + return nil +end + +-- Resolve a warp's destination to map id + cell. LAST_MAP destinations +-- (returning from an interior) resolve against the remembered outdoor +-- map; the landing cell is that map's warp entry named by the warp id +-- (wDestinationWarpID placement -- two-sided route gates land you on +-- the side you exit, not where you entered). +function Warp.destination(data, warpDef, lastMap) + local destMap = warpDef.destMap + if destMap == "LAST_MAP" then + assert(lastMap, "LAST_MAP warp with no remembered outdoor map") + destMap = lastMap.id + local destDef = data.maps[destMap] + local dw = destDef and destDef.warps[warpDef.destWarp] + if dw then + return destMap, dw.x, dw.y + end + -- out-of-range data: fall back to where the player entered + return destMap, lastMap.x, lastMap.y + end + local destDef = data.maps[destMap] + assert(destDef, "warp to unknown map " .. tostring(destMap)) + local dw = destDef.warps[warpDef.destWarp] + assert(dw, ("warp to %s#%d out of range"):format(destMap, warpDef.destWarp)) + return destMap, dw.x, dw.y +end + +return Warp diff --git a/tests/autopilot.lua b/tests/autopilot.lua new file mode 100644 index 00000000..4aba1d90 --- /dev/null +++ b/tests/autopilot.lua @@ -0,0 +1,484 @@ +-- Scripted input driver for visual verification (dev tool). +-- Enable with: POKEPORT_AUTOPILOT=1 love . +-- +-- Steps: waits, key taps, BFS-pathfinding goTo, "mash A until UI closes", +-- callbacks. Wild battles that interrupt navigation are auto-mashed. +-- Captures screenshots into the LÖVE save directory. + +local Autopilot = {} + +local RUN = os.getenv("POKEPORT_AUTOPILOT") +local steps = {} +local pc = 1 +local timer = 0 +local battleTimer = 0 +local pending = {} + +local function game() return require("src.core.Game") end + +-- Anything that makes the overworld ignore normal player input or is +-- waiting on a button: a pushed UI state (text/choice/battle), a +-- script/cutscene in flight (ow.runner, raw scriptMoves, trainer-sight +-- engage), or a timed hold (the "!" emote pause, the heal-machine +-- animation). Mirrors the `scripted` flag OverworldState:update gates +-- real input on, plus the UI-stack and heal-machine cases that flag +-- doesn't cover -- a plain stack-size check reads "idle" the instant a +-- text box closes even mid-cutscene, which let mashUntilIdle() bail out +-- while Oak's escort or the rival's challenge script was still running. +local function busy() + local ow = game().overworld + return game().stack:top() ~= ow + or (ow.runner and ow.runner:isRunning()) + or #(ow.scriptMoves or {}) > 0 + or ow.engaging + or ow.emote ~= nil + or ow.healAnim ~= nil +end + +local function idle() + return not busy() and not game().overworld.transitioning +end + +local function inBattle() + local top = game().stack:top() + return top and top.kind ~= nil -- BattleState has .kind +end + +local function press(key) + game():keypressed(key) + pending[key] = true +end + +-- --------------------------------------------------------------------- +-- BFS pathfinding on the current map (walls + stationary NPCs) +-- --------------------------------------------------------------------- + +local DIRS = { { 0, -1, "w" }, { 0, 1, "s" }, { -1, 0, "a" }, { 1, 0, "d" } } + +local function bfsSearch(tx, ty) + local ow = game().overworld + local map = ow.map + local p = ow.player + if p.cellX == tx and p.cellY == ty then return nil, true end + local w, h = map.widthCells, map.heightCells + local function id(x, y) return y * w + x end + local blocked = {} + for _, npc in ipairs(ow.npcs) do + blocked[id(npc.cellX, npc.cellY)] = true + if npc.targetX then blocked[id(npc.targetX, npc.targetY)] = true end + end + local prev = {} + local queue = { id(p.cellX, p.cellY) } + prev[queue[1]] = -1 + local head = 1 + while head <= #queue do + local cur = queue[head] + head = head + 1 + local cx, cy = cur % w, math.floor(cur / w) + if cx == tx and cy == ty then break end + for _, d in ipairs(DIRS) do + local nx, ny = cx + d[1], cy + d[2] + if nx >= 0 and ny >= 0 and nx < w and ny < h then + local nid = id(nx, ny) + if not prev[nid] and not blocked[nid] + and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then + prev[nid] = cur + table.insert(queue, nid) + end + end + end + end + local goal = id(tx, ty) + if not prev[goal] then return nil, false end + -- walk back to the first step + local cur = goal + while prev[cur] ~= id(p.cellX, p.cellY) do + cur = prev[cur] + if cur == -1 or cur == nil then return nil, true end + end + local cx, cy = cur % w, math.floor(cur / w) + for _, d in ipairs(DIRS) do + if p.cellX + d[1] == cx and p.cellY + d[2] == cy then return d[3], true end + end + return nil, true +end + +local function bfsNextKey(tx, ty) + return (bfsSearch(tx, ty)) +end + +local function reachable(tx, ty) + local _, ok = bfsSearch(tx, ty) + return ok +end + +-- --------------------------------------------------------------------- +-- schedule DSL +-- --------------------------------------------------------------------- + +local function add(step) table.insert(steps, step) end +local function wait(frames) add({ wait = frames }) end +local function shot(name) add({ fn = function() + love.graphics.captureScreenshot(name .. ".png") + print("[autopilot] screenshot " .. name) +end }) end +local function tap(key, times) + for _ = 1, times or 1 do + add({ key = key }) + wait(20) + end +end +-- battleShot: optional { at = frame, name = "shot_name" } -- captured +-- once, `at` frames into a battle that interrupts this goTo (see the +-- auto-mash guard in the runner below). +local function goTo(x, y, battleShot) add({ goto_ = { x = x, y = y }, battleShot = battleShot }) end +local function goToFn(fn) add({ goto_ = { fn = fn } }) end +local function mashUntilIdle() add({ mash = true }) end +local function report(extra) + add({ fn = function() + local ow = game().overworld + print(("[autopilot] map %s at (%d,%d)"):format( + ow.map.id, ow.player.cellX, ow.player.cellY)) + if extra then extra() end + end }) +end + +-- find the warp cell on the current map leading to destMap +local function warpTo(destMap) + return function() + for _, wp in ipairs(game().overworld.map.def.warps) do + if wp.destMap == destMap then return wp.x, wp.y end + end + return nil + end +end + +-- find a cell adjacent to (and facing) the first NPC matching pred; +-- includes across-the-counter spots (intermediate cell is a counter tile) +local function adjacentToNpc(pred) + return function() + local ow = game().overworld + for _, npc in ipairs(ow.npcs) do + if pred(npc) then + local candidates = { + { npc.cellX, npc.cellY + 1, "w" }, { npc.cellX, npc.cellY - 1, "s" }, + { npc.cellX - 1, npc.cellY, "d" }, { npc.cellX + 1, npc.cellY, "a" }, + { npc.cellX, npc.cellY + 2, "w", npc.cellX, npc.cellY + 1 }, + { npc.cellX, npc.cellY - 2, "s", npc.cellX, npc.cellY - 1 }, + { npc.cellX - 2, npc.cellY, "d", npc.cellX - 1, npc.cellY }, + { npc.cellX + 2, npc.cellY, "a", npc.cellX + 1, npc.cellY }, + } + for _, c in ipairs(candidates) do + local counterOk = c[4] == nil or ow.map:isCounterCell(c[4], c[5]) + if counterOk and ow.map:inBounds(c[1], c[2]) + and ow.map:isWalkableCell(c[1], c[2]) + and reachable(c[1], c[2]) then + return c[1], c[2], c[3] + end + end + end + end + return nil + end +end + +local function npcIsMart(npc) + local ow = game().overworld + local entry = game().data:textEntry(ow.map.def.label, npc.def.text) + return entry and entry.mart ~= nil +end + +local function npcIsNurse(npc) + local ow = game().overworld + local entry = game().data:textEntry(ow.map.def.label, npc.def.text) + return entry and entry.nurse == true +end + +-- --------------------------------------------------------------------- +-- the route +-- --------------------------------------------------------------------- + +wait(30) +shot("01_pallet_town") +-- tilt-mode pair: cycle to 15°, let the ~0.25s tween settle, capture, +-- then cycle 35→50→OFF to restore flat +tap("3") +wait(40) +shot("01b_pallet_town_tilt") +for _ = 1, 3 do tap("3") end +wait(40) +goTo(7, 8) +tap("s", 1) +tap("z") +wait(40) +shot("02_sign_text") +mashUntilIdle() +-- Oak's "Hey! Wait!" escort triggers on stepping to Pallet Town row +-- y==1 (PalletTownDefaultScript); from there the walk to the lab, the +-- walk-in, and the choose-a-mon exchange all run as one scripted chain +-- (data/scripts/story2.lua) -- busy()/idle() above track it start to +-- finish, so one mashUntilIdle() rides the whole thing out. Walking +-- straight to the lab door instead (the old route) uses the plain map +-- warp and skips this script entirely -- EVENT_FOLLOWED_OAK_INTO_LAB +-- never gets set, so the starter and rival-battle scripts below stay +-- gated off ("Those are POKé BALLS" / "Gramps isn't around") and the +-- party stays empty for the rest of the run. +goTo(10, 1) +mashUntilIdle() +report() -- expect OAKS_LAB (5,3), EVENT_FOLLOWED_OAK_INTO_LAB set +shot("03_oaks_lab") +goTo(8, 4) +tap("w", 1) +tap("z") -- Bulbasaur ball +wait(60) +shot("04_starter_prompt") +mashUntilIdle() +report() +-- the rival's challenge (data/scripts/oaks_lab.lua onStep) fires the +-- instant the player steps away from the table at y >= 6 -- no direct +-- talk needed (the rival has also already relocated to the counter-pick +-- ball, not a fixed cell). Walking to the exit mat below crosses that +-- row; busy()/battleShot above ride out the resulting text + battle. +goTo(5, 11, { at = 30, name = "05_rival_battle" }) +tap("s", 2) +mashUntilIdle() +report() -- expect PALLET_TOWN (12,11) +-- north to Route 1 (connection strip now rendered) +goTo(10, 1) +shot("06_pallet_north_strip") +goTo(10, 0) +tap("w", 2) +report() -- expect ROUTE_1 (10,35) +shot("07_route1") +-- tilt-mode pair on the route (grass overdraw, water animation, fences). +-- dismiss the entry "tall grass" sign first so the tilt tap isn't gated. +mashUntilIdle() +tap("3") +wait(40) +shot("07c_route1_tilt") +for _ = 1, 3 do tap("3") end +wait(40) +-- guarantee at least one wild battle in the entry grass +add({ grind = { ax = 10, ay = 35, bx = 10, by = 33 }, + shotAt = { [30] = "07b_wild_battle" } }) +-- walk the whole route north to Viridian (BFS pathfinds around the +-- fences and one-way ledges); wild battles are auto-mashed +goTo(10, 0) +tap("w", 2) +report() -- expect VIRIDIAN_CITY +shot("08_viridian") +-- into the mart. By this point in the run EVENT_GOT_STARTER is set but +-- Oak's Parcel hasn't been delivered yet, so talking to the clerk +-- triggers the real Gen 1 quest hand-off (data/scripts/story.lua +-- TEXT_VIRIDIANMART_CLERK: "You came from Pallet Town?" + gives +-- OAKS_PARCEL) instead of opening the shop -- ShopMenu only opens once +-- that quest is resolved (delivered back to Oak), which this short run +-- doesn't do, so there is no purchase to make here. +goToFn(warpTo("VIRIDIAN_MART")) +wait(20) +mashUntilIdle() +report() -- expect VIRIDIAN_MART +goToFn(adjacentToNpc(npcIsMart)) +tap("z") -- talk to the clerk +wait(40) +shot("09_mart_clerk") +mashUntilIdle() -- rides out the parcel hand-off text (or the shop, if flags differ) +shot("10_mart_parcel") +report(function() + local s = game().save + print(("[autopilot] money %d got oaks parcel %s"):format( + s.money, tostring(s.flags.EVENT_GOT_OAKS_PARCEL))) +end) +-- leave the mart, into the center, heal +goToFn(warpTo("LAST_MAP")) +tap("s", 2) +mashUntilIdle() +report() -- back in VIRIDIAN_CITY +goToFn(warpTo("VIRIDIAN_POKECENTER")) +wait(20) +mashUntilIdle() +report() -- expect VIRIDIAN_POKECENTER +goToFn(adjacentToNpc(npcIsNurse)) +tap("z") +wait(60) +shot("11_nurse") +mashUntilIdle() +report(function() + local s = game().save + print(("[autopilot] lastHeal %s (%d,%d) lead hp %d/%d"):format( + s.lastHeal.map, s.lastHeal.x, s.lastHeal.y, + s.party[1].hp, s.party[1].stats.hp)) +end) +shot("12_healed") +-- tilt-mode pair inside the Poke Center (interior + heal-machine area) +tap("3") +wait(40) +shot("12b_pokecenter_tilt") +for _ = 1, 3 do tap("3") end +wait(40) +add({ fn = function() + local s = game().save + print(("[autopilot] party: %s L%d money %d dex seen %d"):format( + s.party[1] and s.party[1].species or "none", + s.party[1] and s.party[1].level or 0, s.money, + (function() local n = 0 for _ in pairs(s.pokedex.seen) do n = n + 1 end return n end)())) + print("[autopilot] save dir: " .. love.filesystem.getSaveDirectory()) + love.event.quit(0) +end }) + +-- --------------------------------------------------------------------- +-- runner +-- --------------------------------------------------------------------- + +function Autopilot.update() + if not RUN then return end + for key in pairs(pending) do + game():keyreleased(key) + pending[key] = nil + end + local step = steps[pc] + if not step then return end + + -- auto-mash through anything that interrupts a step: wild/trainer + -- battles, or a scripted cutscene taking over (busy() -- see above), + -- EXCEPT for goto_ and key steps. goto_: reaching the target cell + -- always completes that step first, even if arrival just triggered a + -- cutscene that will relocate the player (e.g. Oak's escort walking + -- the player into OAKS_LAB) -- letting busy() intercept before goto_ + -- notices it already arrived would freeze pc on a target cell that no + -- longer means anything once the cutscene moves the player elsewhere. + -- key: a scheduled tap() is a deliberate, specific button press for + -- the menu/dialogue on screen right now (e.g. the mart's greeting -> + -- BUY -> item -> quantity -> price steps) -- busy() is true for almost + -- all of those (a UI state is exactly what's on top of the stack), so + -- letting the generic 25-frame mash intercept scrambles a multi-step + -- menu sequence instead of advancing it one deliberate press at a + -- time. mash/grind steps handle their own busy state. + if busy() and not step.mash and not step.grind and not step.goto_ + and not step.key then + battleTimer = battleTimer + 1 + if battleTimer % 25 == 0 then press("z") end + return + end + battleTimer = 0 + + if step.wait then + timer = timer + 1 + if timer >= step.wait then timer = 0 pc = pc + 1 end + elseif step.goto_ then + timer = timer + 1 + local ow = game().overworld + local p = ow.player + local g = step.goto_ + if g.fn and not g.resolved then + local x, y, face = g.fn() + if not x then + print("[autopilot] goto target not found; skipping") + timer = 0 + pc = pc + 1 + return + end + g.x, g.y, g.face = x, y, face + g.resolved = true + end + if p.cellX == g.x and p.cellY == g.y and not p.moving then + if g.face then + -- face the target direction with a tap + if p.facing ~= ({ w = "up", s = "down", a = "left", d = "right" })[g.face] then + press(g.face) + return + end + end + timer = 0 + pc = pc + 1 + return + end + if timer > 5400 and not g.retried then + -- a wandering NPC can park on the target (or the only path to it) + -- for a while; BFS treats it as a wall, so give it one more window + -- to move along instead of derailing every step after this one + g.retried = true + timer = 0 + print("[autopilot] goto slow; retrying once") + return + end + if timer > 5400 then + print("[autopilot] goto timed out") + timer = 0 + pc = pc + 1 + elseif busy() then + -- not there yet and something took over (a trainer/rival's talk + + -- battle, a wild encounter): mash through it, then resume BFS once + -- idle again -- unlike arrival above, this never touches g.x/g.y, + -- so it's safe even though the goto's own target hasn't been hit. + -- battleShot.frames counts frames since the battle itself started + -- (not the shared `timer`, which has already been counting since + -- the walk began) so `at` means "N frames into the battle". + if step.battleShot and inBattle() then + local bs = step.battleShot + bs.frames = (bs.frames or 0) + 1 + if bs.frames == bs.at and not bs.captured then + bs.captured = true + love.graphics.captureScreenshot(bs.name .. ".png") + print("[autopilot] screenshot " .. bs.name) + end + end + if timer % 25 == 0 then press("z") end + elseif idle() and not p.moving then + local key = bfsNextKey(g.x, g.y) + if key and not pending[key] then press(key) end + end + elseif step.grind then + timer = timer + 1 + local g = step.grind + if inBattle() then + g.sawBattle = true + g.battleTimer = (g.battleTimer or 0) + 1 + if step.shotAt and step.shotAt[g.battleTimer] then + love.graphics.captureScreenshot(step.shotAt[g.battleTimer] .. ".png") + print("[autopilot] screenshot " .. step.shotAt[g.battleTimer]) + end + if g.battleTimer % 25 == 0 then press("z") end + elseif g.sawBattle and idle() then + timer = 0 + pc = pc + 1 + elseif idle() then + local p = game().overworld.player + if p.cellX == g.ax and p.cellY == g.ay then g.toB = true end + if p.cellX == g.bx and p.cellY == g.by then g.toB = false end + if not p.moving then + local key = bfsNextKey(g.toB and g.bx or g.ax, g.toB and g.by or g.ay) + if key and not pending[key] then press(key) end + end + end + if timer > 7200 then + print("[autopilot] grind timed out") + timer = 0 + pc = pc + 1 + end + elseif step.key then + press(step.key) + pc = pc + 1 + elseif step.mash then + timer = timer + 1 + if step.shotAt and step.shotAt[timer] then + love.graphics.captureScreenshot(step.shotAt[timer] .. ".png") + print("[autopilot] screenshot " .. step.shotAt[timer]) + end + if timer % 25 == 0 then press("z") end + if idle() and timer % 25 == 24 then + timer = 0 + pc = pc + 1 + end + if timer > 5400 then + print("[autopilot] mash timed out") + timer = 0 + pc = pc + 1 + end + elseif step.fn then + step.fn() + pc = pc + 1 + end +end + +return Autopilot diff --git a/tests/drivers/audio_capture_test.lua b/tests/drivers/audio_capture_test.lua new file mode 100644 index 00000000..d962ad6b --- /dev/null +++ b/tests/drivers/audio_capture_test.lua @@ -0,0 +1,61 @@ +local function le16(value) + return string.char(value % 256, math.floor(value / 256) % 256) +end + +local function le32(value) + return le16(value % 65536) .. le16(math.floor(value / 65536)) +end + +local function writeWav(path, soundData, channels) + local raw = soundData:getString() + local rate, bits = soundData:getSampleRate(), soundData:getBitDepth() + local header = table.concat({ + "RIFF", le32(36 + #raw), "WAVE", + "fmt ", le32(16), le16(1), le16(channels), le32(rate), + le32(rate * channels * bits / 8), le16(channels * bits / 8), + le16(bits), "data", le32(#raw), + }) + local file = assert(io.open(path, "wb")) + file:write(header, raw) + file:close() +end + +return function(game) + local ChipAudio = require("src.core.ChipAudio") + local out = assert(os.getenv("POKEPORT_AUDIO_CAPTURE_DIR")) + local audio = assert(game.data.audio) + + for _, song in ipairs({ "Music_TitleScreen", "Music_PalletTown" }) do + for _, event in ipairs(ChipAudio._traceFirstMusicSampleForTest( + game.data, audio.songs[song])) do + print(("[audio-trace] %s ch%d value=%.4f reg=%s duration=%.4f drum=%s") + :format(song, event.number, event.value, + tostring(event.register), event.duration or 0, + tostring(event.drumSegments))) + end + end + + writeWav(out .. "/title-runtime.wav", + ChipAudio._renderMusicForTest( + game.data, audio.songs.Music_TitleScreen, 8), 2) + writeWav(out .. "/pallet-runtime.wav", + ChipAudio._renderMusicForTest( + game.data, audio.songs.Music_PalletTown, 8), 2) + for channel = 1, 4 do + writeWav(("%s/title-ch%d-runtime.wav"):format(out, channel), + ChipAudio._renderMusicChannelForTest( + game.data, audio.songs.Music_TitleScreen, 8, channel), 1) + end + for channel = 1, 3 do + writeWav(("%s/pallet-ch%d-runtime.wav"):format(out, channel), + ChipAudio._renderMusicChannelForTest( + game.data, audio.songs.Music_PalletTown, 8, channel), 1) + end + writeWav(out .. "/go-inside-runtime.wav", + ChipAudio._renderSfxForTest( + game.data, audio.sfx.Go_Inside, 0.4), 1) + writeWav(out .. "/go-outside-runtime.wav", + ChipAudio._renderSfxForTest( + game.data, audio.sfx.Go_Outside, 0.7), 1) + print("[audio] captured runtime comparison WAVs") +end diff --git a/tests/drivers/audio_runtime_test.lua b/tests/drivers/audio_runtime_test.lua new file mode 100644 index 00000000..499c81a2 --- /dev/null +++ b/tests/drivers/audio_runtime_test.lua @@ -0,0 +1,113 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local ChipAudio = require("src.core.ChipAudio") + assert(game.data.audio and game.data.audio.runtime, + "runtime ROM audio data was not loaded") + local battleAnims = assert(game.data.battle_anims, + "runtime ROM battle animation data was not loaded") + local ImageWriter = require("src.import.ImageWriter") + local transparentTile = ImageWriter.decode2bpp({ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + }, 8, 8, true) + local _, _, _, alpha = transparentTile:getPixel(0, 0) + assert(alpha == 0, "runtime 2bpp color 0 is not transparent") + local encodedTile = assert(transparentTile:encode("png")) + local decodedTile = assert(love.image.newImageData(encodedTile)) + local _, _, _, encodedAlpha = decodedTile:getPixel(0, 0) + assert(encodedAlpha == 0, "runtime PNG encoding discarded transparency") + local animCount = 0 + for _ in pairs(battleAnims.moveAnims) do animCount = animCount + 1 end + assert(animCount == 202, + "runtime ROM battle animation table is incomplete") + for _, sheet in pairs(battleAnims.tilesheets) do + assert(love.filesystem.getInfo(sheet.path, "file"), + "runtime ROM battle animation atlas is missing") + local image = assert(love.image.newImageData(sheet.path)) + local hasTransparentPixel = false + image:mapPixel(function(x, y, r, g, b, a) + hasTransparentPixel = hasTransparentPixel or a == 0 + return r, g, b, a + end) + assert(hasTransparentPixel, + "runtime ROM battle animation atlas has no transparency") + end + local gengar = assert( + game.data.field.intro.gengar.frame1, + "runtime ROM Gengar intro frame metadata is missing") + local gengarImage = assert(love.image.newImageData(gengar.path)) + local hasClearEdge, hasOpaqueWhite = false, false + gengarImage:mapPixel(function(x, y, r, g, b, a) + hasClearEdge = hasClearEdge or a == 0 + hasOpaqueWhite = hasOpaqueWhite + or (r == 1 and g == 1 and b == 1 and a == 1) + return r, g, b, a + end) + assert(hasClearEdge, + "runtime ROM Gengar intro frame has an opaque background") + assert(hasOpaqueWhite, + "runtime ROM Gengar intro matte removed interior white details") + local AnimPlayer = require("src.battle.AnimPlayer") + local player = AnimPlayer.new(battleAnims) + player:start("THUNDERBOLT", true) + assert(#player.steps > 4, + "runtime ROM THUNDERBOLT animation did not compile") + + local title = assert(game.data.audio.songs.Music_TitleScreen) + local pallet = assert(game.data.audio.songs.Music_PalletTown) + local palletTrace = ChipAudio._traceFirstMusicSampleForTest( + game.data, pallet) + assert(palletTrace[1].register == 1782, + "Pallet Town B note was not decoded as a tone") + + local insideTrace = ChipAudio._traceFirstSfxSampleForTest( + game.data, assert(game.data.audio.sfx.Go_Inside)) + assert(insideTrace[1].noiseParameter == 0x44 + and insideTrace[1].volume == 15 + and insideTrace[1].fade == 1, + "Go Inside did not preserve its first NR42/NR43 register values") + + local collisionTrace = ChipAudio._traceFirstSfxSampleForTest( + game.data, assert(game.data.audio.sfx.Collision)) + local sweep = assert(collisionTrace[1].sweep, + "Collision did not preserve its NR10 sweep") + assert(sweep.pace == 5 and sweep.subtract and sweep.shift == 2, + "Collision NR10 sweep was decoded incorrectly") + + local music = assert(ChipAudio.playMusic(game.data, title, true)) + assert(music:getFreeBufferCount() < 8, "title music queued no samples") + assert(music:isPlaying(), "title music source did not start") + + local sfx = assert(ChipAudio.newSfx(game.data, "Press_AB")) + assert(sfx:getDuration() > 0.01, "menu sound is empty") + sfx:play() + + local cry = assert(ChipAudio.newCry(game.data, "PIKACHU")) + assert(cry:getDuration() > 0.01, "Pikachu cry is empty") + cry:play() + + local fanfare = assert(ChipAudio.newSfx(game.data, "Level_Up")) + assert(fanfare:getDuration() > 2.1 and fanfare:getDuration() < 2.3, + ("Level Up timing is wrong: %.3fs"):format(fanfare:getDuration())) + + if os.getenv("POKEPORT_AUDIO_EXHAUSTIVE") == "1" then + local sfxCount, cryCount = 0, 0 + for name in pairs(game.data.audio.sfx) do + assert(ChipAudio.newSfx(game.data, name), + "could not synthesize SFX " .. name) + sfxCount = sfxCount + 1 + end + for species in pairs(game.data.audio.cries) do + assert(ChipAudio.newCry(game.data, species), + "could not synthesize cry " .. species) + cryCount = cryCount + 1 + end + print(("[audio] exhaustive synthesis: %d SFX, %d cries") + :format(sfxCount, cryCount)) + end + + print(("[audio] title queued; Press_AB %.3fs; Pikachu %.3fs; Level_Up %.3fs") + :format(sfx:getDuration(), cry:getDuration(), fanfare:getDuration())) + ChipAudio.stopMusic() + U.wait(2) +end diff --git a/tests/drivers/battle_test.lua b/tests/drivers/battle_test.lua new file mode 100644 index 00000000..c9c24df1 --- /dev/null +++ b/tests/drivers/battle_test.lua @@ -0,0 +1,35 @@ +-- Driver: force a wild battle and screenshot the transition + UI. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + -- a party to fight with + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + -- push a battle straight through the transition + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + U.shot(game, DIR .. "/battle_0_flash.png") + U.wait(10) + U.shot(game, DIR .. "/battle_1_wipe.png") + U.wait(20) + U.shot(game, DIR .. "/battle_2_intro.png") + -- mash to the menu + for _ = 1, 12 do U.tap(game, "a"); U.wait(6) end + U.shot(game, DIR .. "/battle_3_menu.png") + -- FIGHT -> move list + U.tap(game, "a") + U.wait(10) + U.shot(game, DIR .. "/battle_4_moves.png") + -- pick first move, watch the animation + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/battle_5_anim.png") + U.wait(30) + U.shot(game, DIR .. "/battle_6_after.png") +end diff --git a/tests/drivers/battleflow_test.lua b/tests/drivers/battleflow_test.lua new file mode 100644 index 00000000..da8135e3 --- /dev/null +++ b/tests/drivers/battleflow_test.lua @@ -0,0 +1,87 @@ +-- Driver: the two reworked battle flows. +-- A) Old man catch tutorial (DisplayBattleMenu's old-man script): +-- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with +-- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80): +-- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw. +-- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only +-- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local mon = Pokemon.new(game.data, "CHARMANDER", 12) + mon.moves[1] = { id = "MIMIC", pp = 10 } + table.insert(game.save.party, 1, mon) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + local BattleState = require("src.battle.BattleState") + + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + -- ------------------------------------------------ A) old man demo + local demo = BattleState.newWild(game, "WEEDLE", 5) + demo:makeOldManDemo() + demo.onFinish = function() end + ow:pushBattle(demo) + -- through "Wild WEEDLE appeared!" to the scripted battle menu + mashUntil(function() return demo.phase == "menu" and (demo.demoTimer or 0) > 5 end) + U.shot(game, DIR .. "/oldman_0_cursor_fight.png") -- hand on FIGHT + waitFor(function() return (demo.demoTimer or 131) > 95 end) + U.shot(game, DIR .. "/oldman_1_cursor_item.png") -- hand on ITEM + waitFor(function() return game.stack:top() ~= demo end) + U.wait(3) + U.shot(game, DIR .. "/oldman_2_bag.png") -- POKé BALL x50 list + local bag = game.stack:top() + U.tap(game, "b") -- ignored: no backing out + waitFor(function() return bag.hollowIndex ~= nil end) + U.shot(game, DIR .. "/oldman_3_hollow_cursor.png") -- auto-A: hollow '▷' + waitFor(function() return game.stack:top() == demo end) -- list down, throw + U.wait(5) + U.shot(game, DIR .. "/oldman_4_throw.png") -- "OLD MAN used POKé BALL!" + U.tap(game, "a") + U.wait(25) + for i = 5, 7 do + U.wait(55) + U.shot(game, ("%s/oldman_%d_catch.png"):format(DIR, i)) + end + for _ = 1, 20 do U.tap(game, "a"); U.wait(6) end + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) + + -- ------------------------------------------------ B) Mimic mid-move + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + battle.rng = function(a, b) return a end -- Mimic hits + ow:pushBattle(battle) + mashUntil(function() return battle.phase == "menu" end) + U.shot(game, DIR .. "/mimic_0_menu.png") + U.tap(game, "a"); U.wait(8) -- FIGHT + U.shot(game, DIR .. "/mimic_1_moves.png") + U.tap(game, "a"); U.wait(4) -- MIMIC + mashUntil(function() return battle.phase == "mimicSelect" end) + U.shot(game, DIR .. "/mimic_2_chooser.png") -- copy menu at (0,7) + U.tap(game, "down"); U.wait(4) + U.shot(game, DIR .. "/mimic_3_chooser_down.png") + U.tap(game, "a"); U.wait(30) + U.shot(game, DIR .. "/mimic_4_learned.png") -- anim + learned text + mashUntil(function() return battle.phase == "moveSelect" end, 40) + U.wait(2) + U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) + love.event.quit() +end diff --git a/tests/drivers/catch_test.lua b/tests/drivers/catch_test.lua new file mode 100644 index 00000000..ca0bc30c --- /dev/null +++ b/tests/drivers/catch_test.lua @@ -0,0 +1,39 @@ +-- Driver: throw Poké Balls and capture the catch suspense sequence +-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + + -- one throw, screenshotting every 20 frames through the chain + local function throwAndShoot(tag, rng) + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + battle.rng = rng + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + -- what openItems does before BagMenu calls throwBall + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + for _ = 1, 4 do U.tap(game, "a"); U.wait(4) end + for i = 0, 13 do + U.shot(game, ("%s/catch_%s_%02d.png"):format(DIR, tag, i)) + U.wait(18) + end + -- unwind the battle for the next run + for _ = 1, 20 do U.tap(game, "a"); U.wait(6) end + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) + end + + -- rng high: breakout with wobbles, mon reappears + throwAndShoot("break", function(a, b) return b end) + -- rng low: clean capture, ball stays shut + throwAndShoot("caught", function(a, b) return a end) +end diff --git a/tests/drivers/credits_test.lua b/tests/drivers/credits_test.lua new file mode 100644 index 00000000..d6935066 --- /dev/null +++ b/tests/drivers/credits_test.lua @@ -0,0 +1,88 @@ +-- Driver: Hall of Fame end credits (engine/movie/credits.asm), the +-- screen-by-screen fades, mon silhouette wipes, copyright block, THE END, +-- the autosave while THE END is up, and the post-credits soft reset +-- (`jp Init`) back to the boot sequence. Fast-forwards the credits state +-- directly (a real-time run is ~95s) and screenshots the key beats. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- don't clobber a real save: restore (or remove) save.lua afterwards + local prevSave = love.filesystem.read("save.lua") + + U.teleport(game, "HALL_OF_FAME", 4, 2, "right") + local ow = game.overworld + game.save.party = { { species = "PIKACHU", level = 81 } } + -- run the tail of the room script directly (the walk + Oak speech is + -- story.lua's queued cutscene in normal play) + ow.runner:run({ { "record_hall_of_fame" } }) + U.wait(2) + U.shot(game, DIR .. "/credits_0_induction.png") + + -- A through the induction until the credits state is on top + local Credits = require("src.ui.Credits") + local credits + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == Credits then credits = top break end + U.tap(game, "a") + U.wait(2) + end + if not credits then + U.log("FAIL: credits state never appeared") + return + end + + -- fast-forward helper: step the credits state without waiting realtime + local function ffUntil(cond, cap) + for _ = 1, cap or 20000 do + if cond() then break end + credits:update(1 / 60) + end + U.wait(1) -- render one real frame for the screenshot + end + + ffUntil(function() return credits.phase == "intro" end) + U.shot(game, DIR .. "/credits_1_bars.png") + + ffUntil(function() return credits.phase == "fade" end) + for _ = 1, 8 do credits:update(1 / 60) end -- mid-fade (shade 1/3) + U.wait(1) + U.shot(game, DIR .. "/credits_2_fade_in.png") + + ffUntil(function() return credits.phase == "hold" end) + U.shot(game, DIR .. "/credits_3_page1.png") + + ffUntil(function() return credits.phase == "wipe" end) + for _ = 1, 12 do credits:update(1 / 60) end -- silhouette mid-screen + U.wait(1) + U.shot(game, DIR .. "/credits_4_mon_wipe.png") + + ffUntil(function() return credits.index == 4 and credits.phase == "hold" end) + U.shot(game, DIR .. "/credits_5_plain_page.png") + + ffUntil(function() return credits.index == 35 and credits.phase == "hold" end) + U.shot(game, DIR .. "/credits_6_copyright.png") + + ffUntil(function() return credits.phase == "end_hold" end) + U.shot(game, DIR .. "/credits_7_the_end.png") + U.log("save written:", love.filesystem.getInfo("save.lua") ~= nil, + "lastHeal:", game.save.lastHeal and game.save.lastHeal.map) + + ffUntil(function() return credits.phase == "end_wait" end) + U.tap(game, "a") + U.wait(5) + local top = game.stack:top() + U.log("post-credits top state:", + top == game.overworld and "overworld" or tostring(top and "boot" or "none"), + "stack depth:", #game.stack.states) + U.shot(game, DIR .. "/credits_8_soft_reset.png") + + if prevSave then + love.filesystem.write("save.lua", prevSave) + else + love.filesystem.remove("save.lua") + end + U.log("done") +end diff --git a/tests/drivers/door_test.lua b/tests/drivers/door_test.lua new file mode 100644 index 00000000..d434c868 --- /dev/null +++ b/tests/drivers/door_test.lua @@ -0,0 +1,35 @@ +-- Driver: reproduce the door-warp flow. Teleports to Pallet Town in +-- front of Red's house, walks in, tries to move inside, walks back out. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 5, 6, "up") + U.shot(game, DIR .. "/door_0_outside.png") + + -- step onto the door mat (5,5): the warp should fire + U.hold(game, "up", 20) + U.shot(game, DIR .. "/door_1_mid.png") + for i = 2, 6 do + U.wait(6) + U.shot(game, DIR .. ("/door_%d_transition.png"):format(i)) + end + U.wait(30) + U.shot(game, DIR .. "/door_7_inside.png") + local ow = game.overworld + U.log("map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "transitioning:", tostring(ow.transitioning)) + + -- can we move? walk left 2 cells + U.hold(game, "left", 40) + U.log("after-left pos:", ow.player.cellX, ow.player.cellY) + U.shot(game, DIR .. "/door_8_moved_inside.png") + + -- walk back out through the mat + U.hold(game, "right", 40) + U.hold(game, "down", 60) + U.wait(40) + U.shot(game, DIR .. "/door_9_back_outside.png") + U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "transitioning:", tostring(ow.transitioning)) +end diff --git a/tests/drivers/elevator_test.lua b/tests/drivers/elevator_test.lua new file mode 100644 index 00000000..84aee61e --- /dev/null +++ b/tests/drivers/elevator_test.lua @@ -0,0 +1,47 @@ +-- Driver: elevator ride (ShakeElevator, engine/overworld/elevator.asm). +-- Teleports onto the Celadon Mart elevator's exit tile (1,3) -- the real +-- arrival cell, where the floor menu opens on entry -- picks 2F, traces +-- the bgShakeY scroll offset through the 9-frame lead-in and first shake +-- cycles, screenshots both phases of the oscillation, then confirms the +-- post-ride walk-out onto the arrival floor (the car's exit warps are +-- rewritten and the player walks out, per scripts/CeladonMartElevator.asm, +-- instead of a jump-cut warp). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ListMenu = require("src.ui.ListMenu") + + U.teleport(game, "CELADON_MART_ELEVATOR", 1, 3, "up") + U.wait(5) + local ow = game.overworld + U.log("map:", ow.map.id, + "menu open:", tostring(getmetatable(game.stack:top()) == ListMenu)) + U.shot(game, DIR .. "/elev_0_menu.png") + + U.tap(game, "down") -- cursor 1F -> 2F + U.wait(2) + U.tap(game, "a") -- choose 2F: the ElevatorShake state pushes + -- expect 9 zero frames (Celadon farjps into ShakeElevator, no extra + -- Delay3), then -1,-1,+1,+1,... in 2-frame steps + local trace = {} + for _ = 1, 24 do + trace[#trace + 1] = tostring(ow.bgShakeY or 0) + U.wait(1) + end + U.log("bgShakeY after A:", table.concat(trace, ",")) + U.shot(game, DIR .. "/elev_1_shake_a.png") + U.shot(game, DIR .. "/elev_2_shake_b.png") -- 3 frames later: other phase + -- ride out: rest of the 200 shake frames, the PA chime, then the + -- scripted walk-out; wait until the walk-out has warped onto the floor + for _ = 1, 1200 do + U.wait(1) + if ow.map.id ~= "CELADON_MART_ELEVATOR" and not ow.transitioning + and #ow.scriptMoves == 0 then + break + end + end + U.wait(10) + U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "bgShakeY:", tostring(ow.bgShakeY or 0)) + U.shot(game, DIR .. "/elev_3_arrived.png") +end diff --git a/tests/drivers/fossil_menu_test.lua b/tests/drivers/fossil_menu_test.lua new file mode 100644 index 00000000..bb90f9e7 --- /dev/null +++ b/tests/drivers/fossil_menu_test.lua @@ -0,0 +1,79 @@ +-- Driver: Cinnabar Lab fossil-select menu (engine/events/cinnabar_lab.asm +-- GiveFossilToCinnabarLab): talk to scientist 1 carrying two fossils, +-- screenshot the fossil menu, pick one, answer YES on the confirm, and +-- confirm the deposit flags/inventory. Then re-talk and back out with B +-- (ComeAgainText path) to prove nothing is taken on cancel. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Menu = require("src.ui.Menu") + local ChoiceBox = require("src.ui.ChoiceBox") + local Bag = require("src.inventory.Bag") + + Bag.add(game.save, "DOME_FOSSIL", 1) + Bag.add(game.save, "OLD_AMBER", 1) + + U.teleport(game, "CINNABAR_LAB_FOSSIL_ROOM", 5, 3, "up") + local ow = game.overworld + + -- scientist 1 wanders LEFT_RIGHT along row 2: pin him right above us + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.text == "TEXT_CINNABARLABFOSSILROOM_SCIENTIST1" then + npc.wanders, npc.moving = false, false + npc.cellX, npc.cellY = 5, 2 + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.facing = "down" + end + end + U.wait(5) + U.shot(game, DIR .. "/fossil_0_room.png") + + local function topIs(cls) return getmetatable(game.stack:top()) == cls end + local function mash(btn, cond) + for _ = 1, 200 do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return false + end + + -- deposit run: intro -> menu -> pick DOME FOSSIL -> YES -> walk texts + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/fossil_1_intro.png") + U.log("menu reached:", mash("a", function() return topIs(Menu) end)) + U.shot(game, DIR .. "/fossil_2_menu.png") + U.tap(game, "a") -- choose the first entry (DOME FOSSIL) + U.log("confirm reached:", mash("a", function() return topIs(ChoiceBox) end)) + U.shot(game, DIR .. "/fossil_3_confirm.png") + U.tap(game, "a") -- YES + U.log("deposit texts done:", mash("a", function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/fossil_4_done.png") + U.log("GAVE_FOSSIL_TO_LAB:", tostring(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB), + "STILL_REVIVING:", tostring(game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL), + "labFossilMon:", tostring(game.save.labFossilMon)) + U.log("bag DOME_FOSSIL:", tostring(game.save.inventory.DOME_FOSSIL), + "OLD_AMBER:", tostring(game.save.inventory.OLD_AMBER)) + + -- cancel run after the quest resets would need a full revive cycle; + -- instead prove the B-out path on a fresh quest state + game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB = nil + game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL = nil + game.save.labFossilMon = nil + U.tap(game, "a") + U.wait(20) + U.log("menu reached again:", mash("a", function() return topIs(Menu) end)) + U.shot(game, DIR .. "/fossil_5_menu_again.png") + U.tap(game, "b") -- back out + U.log("cancel text done:", mash("a", function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/fossil_6_cancelled.png") + U.log("after cancel OLD_AMBER:", tostring(game.save.inventory.OLD_AMBER), + "GAVE_FOSSIL_TO_LAB:", tostring(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB)) + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/heal_test.lua b/tests/drivers/heal_test.lua new file mode 100644 index 00000000..b339f2a0 --- /dev/null +++ b/tests/drivers/heal_test.lua @@ -0,0 +1,64 @@ +-- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the +-- machine monitor + per-mon balls, the jingle flash, and the farewell. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local ChoiceBox = require("src.ui.ChoiceBox") + local mon = Pokemon.new(game.data, "CHARMANDER", 12) + mon.hp = 3 + table.insert(game.save.party, mon) + local mon2 = Pokemon.new(game.data, "PIDGEY", 8) + mon2.hp = 1 + table.insert(game.save.party, mon2) + U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up") + local ow = game.overworld + + -- mash A until a condition holds + local function mashUntil(cond) + for _ = 1, 400 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + + U.tap(game, "a") -- talk to the nurse + U.wait(30) + U.shot(game, DIR .. "/heal_00_welcome.png") + U.log("choice reached:", mashUntil(function() + return getmetatable(game.stack:top()) == ChoiceBox + end)) + U.shot(game, DIR .. "/heal_01_choice.png") + U.tap(game, "a") -- YES + U.log("machine started:", mashUntil(function() + return ow.healAnim ~= nil + end)) + U.shot(game, DIR .. "/heal_02_ball1.png") + U.wait(28) + U.shot(game, DIR .. "/heal_03_ball2.png") + -- overlay must stay glued to the machine under survey zoom + game:zoomStep(-1); game:zoomStep(-1) + U.wait(3) + U.shot(game, DIR .. "/heal_03z_zoomed.png") + local Zoom = require("src.render.Zoom") + Zoom.reset() + U.wait(3) + U.wait(35) + U.shot(game, DIR .. "/heal_04_flash_a.png") + U.wait(10) + U.shot(game, DIR .. "/heal_05_flash_b.png") + U.wait(10) + U.shot(game, DIR .. "/heal_06_flash_c.png") + -- wait out the jingle + 32-frame beat + for _ = 1, 40 do + if not ow.healAnim then break end + U.wait(10) + end + U.shot(game, DIR .. "/heal_07_fit.png") + U.log("farewell reached:", mashUntil(function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/heal_08_end.png") +end diff --git a/tests/drivers/intro_test.lua b/tests/drivers/intro_test.lua new file mode 100644 index 00000000..7c22e57c --- /dev/null +++ b/tests/drivers/intro_test.lua @@ -0,0 +1,20 @@ +-- Driver: watch the intro movie from boot (does NOT skip). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + -- the boot stack already has IntroMovie on top (Game:load pushed it) + U.wait(30) + U.shot(game, DIR .. "/intro_0_copyright.png") + U.wait(180) + U.shot(game, DIR .. "/intro_1_star.png") + U.wait(90) + U.shot(game, DIR .. "/intro_2_gamefreak.png") + U.wait(120) + U.shot(game, DIR .. "/intro_3_fight_a.png") + U.wait(90) + U.shot(game, DIR .. "/intro_4_fight_b.png") + U.wait(90) + U.shot(game, DIR .. "/intro_5_fight_c.png") + U.wait(120) + U.shot(game, DIR .. "/intro_6_title.png") +end diff --git a/tests/drivers/move_test.lua b/tests/drivers/move_test.lua new file mode 100644 index 00000000..3448a3ba --- /dev/null +++ b/tests/drivers/move_test.lua @@ -0,0 +1,10 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + U.teleport(game, "OAKS_LAB", 7, 4, "up") + local ow = game.overworld + U.log("start:", ow.player.cellX, ow.player.cellY) + U.hold(game, "down", 20) + U.log("after down:", ow.player.cellX, ow.player.cellY) + U.hold(game, "left", 30) + U.log("after left:", ow.player.cellX, ow.player.cellY) +end diff --git a/tests/drivers/oak_test.lua b/tests/drivers/oak_test.lua new file mode 100644 index 00000000..92a8e626 --- /dev/null +++ b/tests/drivers/oak_test.lua @@ -0,0 +1,108 @@ +-- Driver: the full Pallet intro chain. Steps north to trigger Oak, +-- follows him to the lab, takes a starter, watches the rival +-- counter-pick, tries to leave (door gate + ambush battle). + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + -- start two rows south: the trigger is the step ONTO row y=1 + -- (PalletTownDefaultScript's wYCoord == 1 check) + U.teleport(game, "PALLET_TOWN", 10, 3, "up") + local ow = game.overworld + + -- press A only when a dialog is open (mashing at the overworld would + -- re-interact with whatever the player faces) + local function mashUntil(cond, label, cap) + for _ = 1, cap or 400 do + if cond() then return true end + if game.stack:top() ~= ow then + U.tap(game, "a") + end + U.wait(4) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + and not ow.emote -- cutscene DelayFrames/bubble holds + end + + -- trigger Oak (two steps north onto row 1) + U.hold(game, "up", 40) + U.wait(30) + U.shot(game, DIR .. "/oak_1_heywait.png") + U.wait(90) -- auto-close + "!" bubble + U.shot(game, DIR .. "/oak_2_bubble.png") + U.wait(120) -- Oak's zigzag walk up from (8,5) + U.shot(game, DIR .. "/oak_3_follow.png") + -- dismiss "It's unsafe!" (multi-page), then catch the escort mid-walk + mashUntil(function() return game.stack:top() == ow end, "unsafe text", 200) + U.wait(130) + U.shot(game, DIR .. "/oak_3b_escort.png") + mashUntil(function() return ow.map.id == "OAKS_LAB" end, "lab entry", 900) + U.shot(game, DIR .. "/oak_4_arrived.png") + mashUntil(idle, "walk-in done", 600) + U.shot(game, DIR .. "/oak_5_at_desk.png") + U.log("in lab at:", ow.player.cellX, ow.player.cellY) + + -- the walk-in ends at (5,3) below Oak; to the middle ball: + -- down to (5,4) -> right to (7,4) -> face up at SQUIRTLE's ball + U.hold(game, "down", 18) + U.wait(10) + U.hold(game, "right", 34) + U.wait(20) -- let the second step land before turning + U.tap(game, "up") + U.wait(10) + U.log("at ball:", ow.player.cellX, ow.player.cellY, ow.player.facing) + U.tap(game, "a") + U.wait(40) + U.shot(game, DIR .. "/oak_6_ball_prompt.png") + U.tap(game, "a") -- YES + mashUntil(function() return #game.save.party > 0 end, "starter", 200) + U.shot(game, DIR .. "/oak_7_got_starter.png") + -- step away from the ball first: A-mash while facing it re-opens + -- its dialog forever + U.hold(game, "down", 20) + mashUntil(idle, "rival pick done", 400) + U.shot(game, DIR .. "/oak_8_rival_picked.png") + U.log("party:", game.save.party[1] and game.save.party[1].species, + "pos:", ow.player.cellX, ow.player.cellY) + + -- head for the door: rival ambush -> battle. Exact single steps + -- left to column 4 (column 3 runs into the lab table), then south. + for _ = 1, 3 do + U.hold(game, "left", 16) + U.wait(12) + end + U.hold(game, "down", 120) + U.wait(30) + U.shot(game, DIR .. "/oak_9_ambush.png") + local BattleSeen = false + for _ = 1, 200 do + if game.stack:top() ~= ow and game.stack:top() and game.stack:top().kind then + BattleSeen = true + break + end + U.tap(game, "a") + U.wait(4) + end + U.wait(60) + U.shot(game, DIR .. "/oak_10_battle.png") + mashUntil(idle, "battle over", 1500) + U.wait(30) + U.shot(game, DIR .. "/oak_11_after_battle.png") + U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY, + "battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB), + "battleSeen:", tostring(BattleSeen)) + U.log("stuck-state: runner=", tostring(ow.runner:isRunning()), + "moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow), + "transitioning=", tostring(ow.transitioning), + "emote=", tostring(ow.emote ~= nil)) + for i, mv in ipairs(ow.scriptMoves) do + U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format( + i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"), + tostring(mv.dir), mv.remaining, tostring(mv.entity.moving))) + end +end diff --git a/tests/drivers/options_test.lua b/tests/drivers/options_test.lua new file mode 100644 index 00000000..02c2f532 --- /dev/null +++ b/tests/drivers/options_test.lua @@ -0,0 +1,25 @@ +-- Driver: the options screen with the port's audio rows (MUSIC VOL / +-- SFX VOL / MUSIC FILTER) to prove the 4-box viewport, the ▼ scroll +-- marker, and CANCEL fixed on the bottom line. The menu is pushed +-- directly (title-menu row order shifts when a save file exists, so +-- blind taps are unreliable). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + local OptionsMenu = require("src.ui.OptionsMenu") + game.stack:push(OptionsMenu.new(game)) + U.wait(5) + U.shot(game, DIR .. "/options_0_top.png") + for _ = 1, 4 do U.tap(game, "down"); U.wait(2) end + U.shot(game, DIR .. "/options_1_musicvol.png") -- scrolled, ▼ visible + U.tap(game, "left"); U.wait(2) + U.tap(game, "left"); U.wait(2) + U.shot(game, DIR .. "/options_2_musicvol_5.png") + U.tap(game, "down"); U.wait(2) + U.tap(game, "down"); U.wait(2) + U.tap(game, "right"); U.wait(2) -- MUSIC FILTER -> 1X + U.shot(game, DIR .. "/options_3_filter_1x.png") + U.tap(game, "down"); U.wait(2) -- CANCEL, tail rows behind it + U.shot(game, DIR .. "/options_4_cancel.png") +end diff --git a/tests/drivers/rival_exit_test.lua b/tests/drivers/rival_exit_test.lua new file mode 100644 index 00000000..94a0e8f1 --- /dev/null +++ b/tests/drivers/rival_exit_test.lua @@ -0,0 +1,122 @@ +-- Driver: regression coverage for the Oak's lab chain reported broken: +-- * move menu / TYPE-PP box layout (screenshots) +-- * enemy faint slide ends with the pic gone (screenshots) +-- * the rival challenge fires two steps from the table (y==6), +-- no talking required +-- * leaving the lab after the fight takes the LAST_MAP exit mat +-- back to Pallet (previously asserted: no remembered outdoor map) + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- ------------------------------------------------------------ part A + -- wild battle: move menu layout + faint slide + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 20)) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "PIDGEY", 3) + battle.onFinish = function() end + ow:pushBattle(battle) + + for _ = 1, 300 do + if battle.phase == "menu" then break end + U.tap(game, "a"); U.wait(3) + end + U.wait(5) + U.tap(game, "a") -- FIGHT + for _ = 1, 100 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + U.wait(2) + U.shot(game, DIR .. "/v_moves.png") + + -- attack until the Pidgey faints; capture mid-slide and after + local midShot, endShot = false, false + for _ = 1, 1200 do + if battle.enemy and battle.enemy.fainted then + local fx = battle.fx and battle.fx.faint + if fx and fx.frames > 0 and fx.frames <= 20 and not midShot then + U.shot(game, DIR .. "/v_faint_mid.png"); midShot = true + end + if (not fx or fx.frames <= 0) and not endShot then + U.wait(2) + U.shot(game, DIR .. "/v_faint_after.png"); endShot = true + break + end + U.wait(1) + else + U.tap(game, "a"); U.wait(3) + end + end + U.log("faint shots:", tostring(midShot), tostring(endShot)) + + -- ------------------------------------------------------------ part B + -- full Pallet intro -> starter -> two steps down -> ambush -> exit + U.teleport(game, "PALLET_TOWN", 10, 1, "up") + ow = game.overworld + + local function mashUntil(cond, label, cap) + for _ = 1, cap or 400 do + if cond() then return true end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(4) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + U.hold(game, "up", 20) + U.wait(30) + mashUntil(function() return ow.map.id == "OAKS_LAB" end, "lab entry", 600) + mashUntil(idle, "walk-in done", 300) + U.log("lastOutdoor after walk-in:", + ow.lastOutdoor and ow.lastOutdoor.id or "nil", + ow.lastOutdoor and ow.lastOutdoor.x or -1, + ow.lastOutdoor and ow.lastOutdoor.y or -1) + + -- middle ball at (7,3), interact from (7,4) + U.hold(game, "up", 18) + U.hold(game, "right", 52) + U.tap(game, "up") + U.wait(10) + U.tap(game, "a") + U.wait(40) + U.tap(game, "a") -- YES + mashUntil(function() return #game.save.party > 1 end, "starter", 200) + U.hold(game, "down", 20) + mashUntil(idle, "rival pick done", 400) + U.log("picked; at:", ow.player.cellX, ow.player.cellY) + + -- column 4 is the open corridor; two steps down reaches y=6 where + -- the rival must challenge unprompted + U.hold(game, "left", 52) + U.hold(game, "down", 40) + U.wait(20) + local ambushed = false + for _ = 1, 300 do + local top = game.stack:top() + if top ~= ow and top and top.kind then ambushed = true break end + U.tap(game, "a"); U.wait(4) + end + U.log("ambushed:", tostring(ambushed), "at y:", ow.player.cellY) + U.shot(game, DIR .. "/v_ambush.png") + mashUntil(idle, "rival battle over", 2000) + U.log("battled flag:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB)) + + -- out the door: exit mats at (4,11)/(5,11) are LAST_MAP edge warps + U.hold(game, "down", 140) + U.wait(30) + U.hold(game, "down", 30) -- edge exit off the mat + U.wait(60) + mashUntil(idle, "exit settled", 200) + U.log("after exit:", ow.map.id, ow.player.cellX, ow.player.cellY) + U.shot(game, DIR .. "/v_exit.png") +end diff --git a/tests/drivers/seam_test.lua b/tests/drivers/seam_test.lua new file mode 100644 index 00000000..9cd06c63 --- /dev/null +++ b/tests/drivers/seam_test.lua @@ -0,0 +1,22 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + U.teleport(game, "ROUTE_1", 10, 4, "up") + local ow = game.overworld + local lastPy, lastMap + for i = 1, 90 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + coroutine.yield() + local p = ow.player + -- log the on-screen player y and world-pos delta each frame + local scrY = p.py - ow.camera.y + -- detect a stall: same world py two frames running while holding up + U.log(("f=%d %-14s py=%d scrY=%.0f moving=%s"):format( + i, ow.map.id, p.py, scrY, tostring(p.moving))) + if ow.map.id == "VIRIDIAN_CITY" and p.cellY < 33 then break end + end + game.input.state.up = false +end diff --git a/tests/drivers/shop_test.lua b/tests/drivers/shop_test.lua new file mode 100644 index 00000000..e8ad8a40 --- /dev/null +++ b/tests/drivers/shop_test.lua @@ -0,0 +1,63 @@ +-- Driver: mart buy flow, greeting, BUY list, purchase, unwind, then +-- confirm the player can still walk (softlock check). Runs both the +-- generic mart (Pewter) and the script-run mart (Viridian post-parcel, +-- open_mart with a yielded runner, the old softlock). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Flags = require("src.script.Flags") + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local QuantityBox = require("src.ui.QuantityBox") + local ChoiceBox = require("src.ui.ChoiceBox") + game.save.money = 3000 + Flags.set(game.save, "EVENT_GOT_STARTER") + Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL") + Flags.set(game.save, "EVENT_OAK_GOT_PARCEL") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + local function mash(btn, cond) + for _ = 1, 120 do + if cond() then return true end + U.tap(game, btn) + U.wait(4) + end + return false + end + + local function buyRun(tag) + local ow = game.overworld + U.tap(game, "a") -- talk to the clerk + U.wait(20) + U.log(tag, "menu:", mash("a", function() return topIs(Menu) end)) + U.tap(game, "a") -- BUY + U.wait(8) + U.log(tag, "list:", topIs(ListMenu)) + U.shot(game, ("%s/%s_0_list.png"):format(DIR, tag)) + U.tap(game, "a") -- first item + U.wait(8) + U.log(tag, "qty:", topIs(QuantityBox)) + U.tap(game, "a") -- x01 + U.wait(8) + U.log(tag, "confirm:", topIs(ChoiceBox)) + U.shot(game, ("%s/%s_1_confirm.png"):format(DIR, tag)) + U.tap(game, "a") -- YES + U.wait(8) + U.shot(game, ("%s/%s_2_bought.png"):format(DIR, tag)) + U.log(tag, "unwound:", mash("b", function() return game.stack:top() == ow end)) + U.log(tag, "runner idle:", + not (ow.runner and ow.runner:isRunning()) and true or false) + local x0, y0 = ow.player.cellX, ow.player.cellY + U.hold(game, "right", 30) + U.wait(20) + U.log(tag, "player moved:", ow.player.cellX ~= x0 or ow.player.cellY ~= y0) + U.shot(game, ("%s/%s_3_walk.png"):format(DIR, tag)) + end + + U.teleport(game, "PEWTER_MART", 2, 5, "left") + buyRun("pewter") + U.teleport(game, "VIRIDIAN_MART", 2, 5, "left") + buyRun("viridian") +end diff --git a/tests/drivers/shrink_test.lua b/tests/drivers/shrink_test.lua new file mode 100644 index 00000000..6b8a6e57 --- /dev/null +++ b/tests/drivers/shrink_test.lua @@ -0,0 +1,64 @@ +-- Driver: the Oak speech from NEW GAME through the shrink-away beat +-- (engine/movie/oak_speech/oak_speech.asm .next): RedPicFront -> +-- ShrinkPic1 -> ShrinkPic2 -> walking sprite -> fade to white -> +-- Pallet Town. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(10) + U.tap(game, "a") -- title -> menu + U.wait(5) + -- with an existing save the menu is CONTINUE / NEW GAME / OPTION + local ok, saved = pcall(function() + return require("src.core.SaveData").load() ~= nil + end) + if ok and saved then U.tap(game, "down") U.wait(3) end + U.tap(game, "a") -- NEW GAME + U.wait(10) + + -- mash through the speech + both naming screens (preset 1) + local function top() return game.stack:top() end + local function speechState() + for _, s in ipairs(game.stack.states or {}) do + if s.shrink ~= nil or s.oakPic ~= nil then return s end + end + end + for _ = 1, 400 do + local s = speechState() + if s and s.step and s.step >= 9 then break end + U.tap(game, "a") + U.wait(2) + end + + -- the shrink beat: captures aimed at each timeline window, with the + -- exact frame logged so the windows can be verified + local s = speechState() + local function frameNow() + return (s and s.shrink and s.shrink.frame) or -1 + end + U.log("shrink beat entered:", tostring(s ~= nil and s.shrink ~= nil), + "frame:", frameNow()) + U.shot(game, DIR .. "/shrink_1_redpic.png") -- frames 1-4: RedPicFront + U.log("shot1 frame:", frameNow()) + U.wait(3) + U.shot(game, DIR .. "/shrink_2_pic1.png") -- frames 5-8: ShrinkPic1 + U.log("shot2 frame:", frameNow()) + U.wait(8) + U.shot(game, DIR .. "/shrink_3_pic2.png") -- frames 9-28: ShrinkPic2 + U.log("shot3 frame:", frameNow()) + U.wait(25) + U.shot(game, DIR .. "/shrink_4_sprite.png") -- frames 29-78: walk sprite + U.log("shot4 frame:", frameNow()) + U.wait(43) + U.shot(game, DIR .. "/shrink_5_fade.png") -- frames 79-102: fade + U.log("shot5 frame:", frameNow()) + U.wait(15) + U.wait(30) + U.shot(game, DIR .. "/shrink_6_overworld.png") + U.log("after speech: top==overworld:", tostring(top() == game.overworld), + "map:", game.overworld and game.overworld.map + and game.overworld.map.id or "?") +end diff --git a/tests/drivers/slots_test.lua b/tests/drivers/slots_test.lua new file mode 100644 index 00000000..b784ddfd --- /dev/null +++ b/tests/drivers/slots_test.lua @@ -0,0 +1,105 @@ +-- Driver: Game Corner slot machine, open a working machine, spin, stop +-- the three wheels one at a time (per-wheel slip animation), read the +-- result, then poke the three broken machines for their exact pokered +-- texts (OUT OF ORDER / OUT TO LUNCH / SOMEONE'S KEYS). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local SlotMachine = require("src.ui.SlotMachine") + local TextBox = require("src.render.TextBox") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + local function pageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local out = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = tostring(line) end + end + return table.concat(out, "\\n") + end + + U.newGame(game) + game.save.inventory = game.save.inventory or {} + game.save.inventory.COIN_CASE = 1 + game.save.coins = 50 + + -- seat 1: working machine at (18,15); stand left of it facing right + U.teleport(game, "GAME_CORNER", 17, 15, "right") + U.tap(game, "a") + U.wait(10) + U.log("machine open:", topIs(SlotMachine)) + U.shot(game, DIR .. "/slots_0_bet.png") + + local sm = game.stack:top() + U.tap(game, "a") -- PromptUserToPlaySlots "Want to play?" -> YES (default cursor) + U.wait(6) + U.log("bet stage:", sm.stage == "bet", "default bet (x3):", sm.bet) + U.tap(game, "a") -- confirm bet 3 (CoinMultiplierSlotMachineText default), start spinning + U.wait(6) + U.log("spinup:", sm.stage == "spinup", "coins:", game.save.coins) + U.wait(44) -- 20 spin-up steps at 2 frames each + U.log("spinning:", sm.stage == "spin") + U.shot(game, DIR .. "/slots_1_spin.png") + + U.tap(game, "a") -- stop wheel 1 + U.wait(30) + U.log("wheel1 stopped:", sm.stopping >= 1 and sm.slip[1] == 0, + "offset odd:", sm.offset[1] % 2 == 1) + U.shot(game, DIR .. "/slots_2_wheel1.png") + + U.tap(game, "a") -- stop wheel 2 + U.wait(30) + U.log("wheel2 stopped:", sm.stopping >= 2 and sm.slip[2] == 0, + "offset odd:", sm.offset[2] % 2 == 1) + U.shot(game, DIR .. "/slots_3_wheel2.png") + + U.tap(game, "a") -- stop wheel 3 + U.wait(30) + -- a win goes through the screen-flash stage before the "lined up!" + -- message; a loss reaches the "Not this time!" message immediately. + for _ = 1, 200 do + if sm.stage == "message" then break end + U.wait(1) + end + U.log("resolved:", sm.stage == "message", + "offsets:", sm.offset[1], sm.offset[2], sm.offset[3]) + U.log("message:", tostring(sm.message), "coins:", game.save.coins) + U.shot(game, DIR .. "/slots_4_result.png") + + U.tap(game, "a") -- dismiss result (starts the coin-drip payout on a win) + U.wait(6) + -- ride out the payout drip (if any) until "One more go?" or the + -- out-of-coins auto-exit + for _ = 1, 3000 do + if sm.stage == "onemore" or (sm.stage == "message" and sm.exitTimer) then break end + U.wait(1) + end + U.log("after payout:", sm.stage, "coins:", game.save.coins) + + if sm.stage == "onemore" then + U.tap(game, "b") -- decline "One more go?" -> leave + U.wait(6) + else + U.wait(70) -- OutOfCoinsSlotMachineText auto-exits after 60 frames + end + U.log("left machine:", game.stack:top() == game.overworld) + + -- broken machines: exact pokered strings + local spots = { + { 12, 12, "out_to_lunch", "slots_5_lunch" }, + { 5, 12, "out_of_order", "slots_6_order" }, + { 17, 10, "keys", "slots_7_keys" }, + } + for _, s in ipairs(spots) do + U.teleport(game, "GAME_CORNER", s[1], s[2], "right") + U.tap(game, "a") + U.wait(10) + U.log(s[3] .. ":", pageText()) + U.shot(game, DIR .. "/" .. s[4] .. ".png") + U.tap(game, "a") + U.wait(6) + end +end diff --git a/tests/drivers/trainer_sight_test.lua b/tests/drivers/trainer_sight_test.lua new file mode 100644 index 00000000..fd048069 --- /dev/null +++ b/tests/drivers/trainer_sight_test.lua @@ -0,0 +1,38 @@ +-- Driver: trainer sight-line walk-up timing (home/trainers.asm +-- CheckFightingMapTrainers). Teleports to Route 3 one tile outside the +-- range-2 sight line of the Youngster at (10,6) (faces right), then walks +-- left INTO the line while keeping the d-pad held. The player must +-- freeze on the detection tile (12,6): the "!" shows, the trainer walks +-- exactly one step to (11,6) and the battle text opens -- the held +-- direction must never buy another step. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "ROUTE_3", 13, 6, "left") + local ow = game.overworld + U.shot(game, DIR .. "/sight_0_before.png") + + -- hold left well past detection (step 16f + detect 1f + bubble 60f) + U.hold(game, "left", 30) + U.log("at-detect pos:", ow.player.cellX, ow.player.cellY, + "engaging:", tostring(ow.engaging), + "emote:", tostring(ow.emote and ow.emote.frames)) + U.shot(game, DIR .. "/sight_1_exclaim.png") + U.hold(game, "left", 60) + U.log("post-bubble pos:", ow.player.cellX, ow.player.cellY, + "moving:", tostring(ow.player.moving)) + U.shot(game, DIR .. "/sight_2_walkup.png") + + -- let the walk-up finish and the pre-battle text open + U.wait(40) + U.shot(game, DIR .. "/sight_3_text.png") + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == 2 then trainer = npc end + end + U.log("final player:", ow.player.cellX, ow.player.cellY, + "trainer:", trainer and trainer.cellX, trainer and trainer.cellY, + "facing:", trainer and trainer.facing, + "top-is-overworld:", tostring(game.stack:top() == ow)) +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua new file mode 100644 index 00000000..4f843460 --- /dev/null +++ b/tests/drivers/util.lua @@ -0,0 +1,74 @@ +-- Shared helpers for POKEPORT_DRIVER scripts (frame-stepped coroutines +-- run by main.lua under xvfb for scripted screenshots). + +local U = {} + +local frame = 0 + +function U.wait(n) + for _ = 1, n do + frame = frame + 1 + coroutine.yield() + end +end + +-- tap a button for one frame, then release it (the driver has no +-- keyreleased, so an unreleased button would stay held forever and +-- shadow later directional input) +function U.tap(game, btn) + table.insert(game.input.pressQueue, btn) + U.wait(1) + game.input.state[btn] = false +end + +-- hold a direction for n frames +function U.hold(game, btn, n) + for _ = 1, n do + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false +end + +function U.shot(game, path) + game.capturePath = path + U.wait(2) -- let the capture flush +end + +-- skip the intro movie + title into a fresh overworld game +function U.newGame(game) + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(10) + U.tap(game, "a") -- title -> menu + U.wait(5) + -- menu: CONTINUE may or may not exist; NEW GAME is first without a save + U.tap(game, "a") + U.wait(10) + -- Oak speech: mash through text + naming (presets pick first = RED). + -- The closing shrink-away beat (~103 frames) is not skippable, like + -- the DelayFrames chain it ports, so leave headroom. + for _ = 1, 400 do + U.tap(game, "a") + U.wait(2) + if game.overworld and game.stack:top() == game.overworld then break end + end + U.wait(10) +end + +-- jump straight into the overworld at a position, bypassing the intro +function U.teleport(game, mapId, x, y, facing) + while game.stack:top() do game.stack:pop() end + local OverworldState = require("src.world.OverworldController") + game.stack:push(OverworldState, mapId, x, y, facing or "down") + U.wait(5) +end + +function U.log(...) + print("[driver]", ...) +end + +function U.frame() return frame end + +return U diff --git a/tests/drivers/verify_test.lua b/tests/drivers/verify_test.lua new file mode 100644 index 00000000..63d5e954 --- /dev/null +++ b/tests/drivers/verify_test.lua @@ -0,0 +1,22 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 10)) + -- battle transition + UI + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "RATTATA", 6) + b.onFinish = function() end + ow:pushBattle(b) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + U.shot(game, DIR .. "/v_battle_menu.png") + -- START menu gating (empty dex, has party) + while game.stack:top() ~= b do U.wait(1) end + -- exit battle + b.result = "run"; b:finish() + U.wait(20) + U.tap(game, "start"); U.wait(6) + U.shot(game, DIR .. "/v_startmenu.png") +end diff --git a/tests/drivers/zoom_test.lua b/tests/drivers/zoom_test.lua new file mode 100644 index 00000000..a1e90a0d --- /dev/null +++ b/tests/drivers/zoom_test.lua @@ -0,0 +1,49 @@ +-- Driver: overworld survey zoom -- screenshots at several levels, UI +-- over the zoomed world, and Rock Tunnel darkness zoomed out. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Zoom = require("src.render.Zoom") + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(30) + U.shot(game, DIR .. "/zoom_0_default.png") + + -- two clicks out + game:zoomStep(-1); game:zoomStep(-1) + U.wait(5) + U.shot(game, DIR .. "/zoom_1_out2.png") + + -- full survey (clamps at s'=1): Route 1 ghosts should be wandering + for _ = 1, 10 do game:zoomStep(-1) end + U.wait(60) + U.shot(game, DIR .. "/zoom_2_survey.png") + + -- UI on top of the zoomed world + U.tap(game, "start") + U.wait(10) + U.shot(game, DIR .. "/zoom_3_menu_over_survey.png") + U.tap(game, "b") + U.wait(5) + + -- close-up (clamps at 2*S) + for _ = 1, 30 do game:zoomStep(1) end + U.wait(5) + U.shot(game, DIR .. "/zoom_4_closeup.png") + + -- the Route 1 / Pallet seam: two palette zones visible at once + Zoom.reset() + U.teleport(game, "ROUTE_1", 5, 5, "down") + for _ = 1, 6 do game:zoomStep(-1) end + U.wait(30) + U.shot(game, DIR .. "/zoom_6_seam_palettes.png") + + -- Rock Tunnel darkness while zoomed out + Zoom.reset() + U.teleport(game, "ROCK_TUNNEL_1F", 8, 15, "down") + for _ = 1, 4 do game:zoomStep(-1) end + U.wait(10) + U.shot(game, DIR .. "/zoom_5_dark_survey.png") + + Zoom.reset() +end diff --git a/tests/love_stub.lua b/tests/love_stub.lua new file mode 100644 index 00000000..a2900981 --- /dev/null +++ b/tests/love_stub.lua @@ -0,0 +1,79 @@ +-- Minimal love API stub so game logic can run headless under plain Lua +-- (lua5.4 tests/run_tests.lua). Graphics calls are no-ops that record +-- enough state for assertions. + +local stub = {} + +local function noop() end + +local Image = {} +Image.__index = Image +function Image:getDimensions() return self.w, self.h end +function Image:getWidth() return self.w end +function Image:getHeight() return self.h end + +-- read PNG dimensions from the file header (no decoder needed) +local function pngSize(path) + local f = io.open(path, "rb") + if not f then return 8, 8 end + local header = f:read(24) + f:close() + if not header or #header < 24 then return 8, 8 end + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be32(header, 17), be32(header, 21) +end + +local files = {} -- in-memory love.filesystem + +stub.graphics = { + newImage = function(path) + local w, h = pngSize(path) + return setmetatable({ w = w, h = h, path = path }, Image) + end, + newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + newCanvas = function(w, h) + return setmetatable({ w = w, h = h, setFilter = noop }, Image) + end, + newSpriteBatch = function(image, size) + local batch = { image = image, sprites = {} } + function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end + return batch + end, + draw = noop, rectangle = noop, setColor = noop, clear = noop, + setCanvas = noop, setDefaultFilter = noop, print = noop, + -- coordinate-transform + state stack used by the tilt-mode upright pass + -- (billboards); plain no-ops here (tests that need to observe them swap + -- in their own recorders, e.g. tests/parity_tilt.lua) + push = noop, pop = noop, translate = noop, scale = noop, + rotate = noop, origin = noop, setShader = noop, setScissor = noop, + getDimensions = function() return 640, 576 end, +} + +stub.math = { + random = function(a, b) + if a == nil then return math.random() end + if b == nil then return math.random(a) end + return math.random(a, b) + end, +} + +stub.filesystem = { + write = function(name, content) files[name] = content return true end, + read = function(name) return files[name] end, + getInfo = function(name) return files[name] and { type = "file" } or nil end, + load = function(name) + if not files[name] then return nil, "no file" end + return load(files[name], name) + end, +} + +stub.keyboard = { isDown = function() return false end } + +stub.mouse = { getPosition = function() return 0, 0 end } + +stub.timer = { getTime = function() return 0 end } + +return stub diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua new file mode 100644 index 00000000..e982cdec --- /dev/null +++ b/tests/mod_runtime_tests.lua @@ -0,0 +1,40 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Registry = require("src.mods.Registry") +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") +local Manifest = require("src.mods.Manifest") + +local function check(value, message) + assert(value, message) +end + +local registry = Registry.new("pokemon") +registry:register("A", { value = 1 }, "test") +registry:override("A", { value = 2 }, "test") +check(registry:get("A").value == 2, "registry override") + +local events = Events.new() +local calls = {} +events:on("test", function() calls[#calls + 1] = "low" end, 0) +events:on("test", function() calls[#calls + 1] = "high" end, 10) +events:emit("test") +check(calls[1] == "high" and calls[2] == "low", "event priority") + +local hooks = Hooks.new() +hooks:wrap("double", function(next, value) + return next(value) * 2 +end, 0) +hooks:wrap("double", function(next, value) + return next(value + 1) +end, 10) +check(hooks:call("double", function(value) return value end, 3) == 8, + "hook chain ordering and next") + +local manifest = Manifest.validate({ + id = "test_mod", name = "Test Mod", version = "1.0.0", entry = "main.lua" +}, "mods/test_mod") +check(manifest.id == "test_mod" and manifest.path == "mods/test_mod", + "manifest validation") + +print("ok native mod runtime") diff --git a/tests/parity_A.lua b/tests/parity_A.lua new file mode 100644 index 00000000..1b45a959 --- /dev/null +++ b/tests/parity_A.lua @@ -0,0 +1,268 @@ +-- Parity test, Workstream A (gym-guide batch). +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.PALLET_TOWN) then Data:load() end +local unpack = table.unpack or unpack +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions === + +local init = require("data.scripts.init") + +local guides = { + { "CERULEAN_GYM", "TEXT_CERULEANGYM_GYM_GUIDE" }, + { "CINNABAR_GYM", "TEXT_CINNABARGYM_GYM_GUIDE" }, + { "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_GYM_GUIDE" }, + { "GAME_CORNER", "TEXT_GAMECORNER_GYM_GUIDE" }, + { "PEWTER_GYM", "TEXT_PEWTERGYM_GYM_GUIDE" }, + { "SAFFRON_GYM", "TEXT_SAFFRONGYM_GYM_GUIDE" }, + { "VERMILION_GYM", "TEXT_VERMILIONGYM_GYM_GUIDE" }, + { "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GYM_GUIDE" }, +} + +-- (1) all 8 gym-guide talk scripts are registered and non-nil +for _, g in ipairs(guides) do + local mapId, textConst = g[1], g[2] + local script = init.talkScript(mapId, textConst) + check(script ~= nil, ("%s.%s registered (non-nil)"):format(mapId, textConst)) + check(type(script) == "table" or type(script) == "function", + ("%s.%s is a table or function"):format(mapId, textConst)) +end + +-- helper: does a row-list script reference a given text label anywhere? +local function referencesLabel(rows, label) + for _, r in ipairs(rows) do + for _, v in ipairs(r) do + if v == label then return true end + end + end + return false +end + +-- (2) badge-branch guides (row-list style): confirm both branches +-- reference the real extracted pokered labels, and drive both paths. + +local function driveBadgeBranch(mapId, textConst, beatFlag, champLabel, beatLabel, desc) + local script = init.talkScript(mapId, textConst) + check(type(script) == "table", desc .. " is a row-list table") + if type(script) ~= "table" then return end + check(referencesLabel(script, champLabel), desc .. " references " .. champLabel) + check(referencesLabel(script, beatLabel), desc .. " references " .. beatLabel) + + -- run it with a stub ctx (no badge) -> should land on champLabel + local Commands = require("src.script.Commands") + local shown = {} + local origShowText = Commands.show_text + Commands.show_text = function(ctx, textId) table.insert(shown, textId) end + local ctx = { save = { flags = {} } } + local function run(flags) + ctx.save = { flags = flags } + shown = {} + local pc = 1 + while pc <= #script do + local row = script[pc] + local fn = Commands[row[1]] + local jump = fn(ctx, select(2, unpack(row))) + if type(jump) == "number" then pc = jump else pc = pc + 1 end + end + end + run({}) + eq(shown[1], champLabel, desc .. " (no badge) shows champ-in-making text") + run({ [beatFlag] = true }) + eq(shown[1], beatLabel, desc .. " (beaten) shows post-badge text") + Commands.show_text = origShowText +end + +driveBadgeBranch("CERULEAN_GYM", "TEXT_CERULEANGYM_GYM_GUIDE", "EVENT_BEAT_MISTY", + "_CeruleanGymGymGuideChampInMakingText", "_CeruleanGymGymGuideBeatMistyText", + "CERULEAN_GYM gym guide") + +driveBadgeBranch("VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GYM_GUIDE", "EVENT_BEAT_GIOVANNI", + "_ViridianGymGuidePreBattleText", "_ViridianGymGuidePostBattleText", + "VIRIDIAN_GYM gym guide") + +-- (3) the remaining simple badge-branch guides at least reference their +-- real pokered labels +local simple = { + { "CINNABAR_GYM", "TEXT_CINNABARGYM_GYM_GUIDE", + "_CinnabarGymGymGuideChampInMakingText", "_CinnabarGymGymGuideBeatBlaineText" }, + { "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_GYM_GUIDE", + "_FuchsiaGymGymGuideChampInMakingText", "_FuchsiaGymGymGuideBeatKogaText" }, + { "GAME_CORNER", "TEXT_GAMECORNER_GYM_GUIDE", + "_GameCornerGymGuideChampInMakingText", "_GameCornerGymGuideTheyOfferRarePokemonText" }, + { "SAFFRON_GYM", "TEXT_SAFFRONGYM_GYM_GUIDE", + "_SaffronGymGuideChampInMakingText", "_SaffronGymGuideBeatSabrinaText" }, + { "VERMILION_GYM", "TEXT_VERMILIONGYM_GYM_GUIDE", + "_VermilionGymGymGuideChampInMakingText", "_VermilionGymGymGuideBeatLTSurgeText" }, +} +for _, s in ipairs(simple) do + local mapId, textConst, champLabel, beatLabel = s[1], s[2], s[3], s[4] + local script = init.talkScript(mapId, textConst) + check(type(script) == "table", mapId .. " gym guide is a row-list table") + if type(script) == "table" then + check(referencesLabel(script, champLabel), mapId .. " gym guide references " .. champLabel) + check(referencesLabel(script, beatLabel), mapId .. " gym guide references " .. beatLabel) + end +end + +-- (4) Pewter's YES/NO branch: confirm all four real text labels appear +do + local script = init.talkScript("PEWTER_GYM", "TEXT_PEWTERGYM_GYM_GUIDE") + check(type(script) == "table", "PEWTER_GYM gym guide is a row-list table") + if type(script) == "table" then + for _, label in ipairs({ + "_PewterGymGuidePreAdviceText", "_PewterGymGuideBeginAdviceText", + "_PewterGymGuideFreeServiceText", "_PewterGymGuideAdviceText", + "_PewterGymGuidePostBattleText", + }) do + check(referencesLabel(script, label), "PEWTER_GYM gym guide references " .. label) + end + -- confirm it actually contains an "ask" row (the YES/NO branch) + local hasAsk = false + for _, r in ipairs(script) do + if r[1] == "ask" then hasAsk = true end + end + check(hasAsk, "PEWTER_GYM gym guide asks a YES/NO question") + end +end + +-- (5) all 8 real extracted text labels exist in generated text data +for _, label in ipairs({ + "_CeruleanGymGymGuideChampInMakingText", "_CeruleanGymGymGuideBeatMistyText", + "_CinnabarGymGymGuideChampInMakingText", "_CinnabarGymGymGuideBeatBlaineText", + "_FuchsiaGymGymGuideChampInMakingText", "_FuchsiaGymGymGuideBeatKogaText", + "_GameCornerGymGuideChampInMakingText", "_GameCornerGymGuideTheyOfferRarePokemonText", + "_PewterGymGuidePreAdviceText", "_PewterGymGuideBeginAdviceText", + "_PewterGymGuideFreeServiceText", "_PewterGymGuideAdviceText", + "_PewterGymGuidePostBattleText", + "_SaffronGymGuideChampInMakingText", "_SaffronGymGuideBeatSabrinaText", + "_VermilionGymGymGuideChampInMakingText", "_VermilionGymGymGuideBeatLTSurgeText", + "_ViridianGymGuidePreBattleText", "_ViridianGymGuidePostBattleText", +}) do + check(Data.text and Data.text[label] ~= nil, "extracted text exists: " .. label) +end + +-- (6) gym leader talk handlers (data/scripts/gyms.lua): pre-badge talk +-- routes into engageTrainer (leader battle), post-badge talk shows the +-- leader's faithful advice text; Giovanni additionally disappears. +do + local leaders = { + { "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK" }, + { "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY" }, + { "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE" }, + { "CELADON_GYM", "TEXT_CELADONGYM_ERIKA" }, + { "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA" }, + { "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA" }, + { "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE" }, + { "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI" }, + } + for _, l in ipairs(leaders) do + check(type(init.talkScript(l[1], l[2])) == "function", + l[1] .. "." .. l[2] .. " leader talk registered (function handler)") + end + + -- the post-badge advice labels all exist in the extracted text + for _, label in ipairs({ + "_PewterGymBrockPostBattleAdviceText", + "_CeruleanGymMistyTM11ExplanationText", + "_VermilionGymLTSurgePostBattleAdviceText", + "_CeladonGymErikaPostBattleAdviceText", + "_FuchsiaGymKogaPostBattleAdviceText", + "_SaffronGymSabrinaPostBattleAdviceText", + "_CinnabarGymBlainePostBattleAdviceText", + "_ViridianGymGiovanniPostBattleAdviceText", + }) do + check(Data.text and Data.text[label] ~= nil, "extracted text exists: " .. label) + end + + -- drive both branches with a capturing TextBox stub + local realTB = package.loaded["src.render.TextBox"] + package.loaded["src.render.TextBox"] = { + new = function(game, text, done) return { text = text, onDone = done } end, + } + + local function driveLeader(mapId, textConst, beatFlag) + local pushed, engaged + local game = { + save = { flags = {} }, + data = Data, + stack = { push = function(_, tb) pushed = tb end }, + } + local ow = { map = { id = mapId }, npcs = {}, entities = {}, + engageTrainer = function() engaged = true end } + local script = init.talkScript(mapId, textConst) + script(game, ow, { def = {} }, function() end) + check(engaged and not pushed, + mapId .. " leader talk (no badge) engages the leader battle") + engaged, pushed = nil, nil + game.save.flags[beatFlag] = true + local state = {} + script(game, ow, { def = {} }, function() state.doneCalled = true end) + check(pushed and not engaged, + mapId .. " leader talk (beaten) shows a text box instead") + return game, pushed, state + end + + local _, box = driveLeader("CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", + "EVENT_BEAT_MISTY") + eq(box and box.text, Data.text._CeruleanGymMistyTM11ExplanationText, + "Misty (beaten) shows the TM11 explanation text") + + _, box = driveLeader("CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", + "EVENT_BEAT_BLAINE") + eq(box and box.text, Data.text._CinnabarGymBlainePostBattleAdviceText, + "Blaine (beaten) shows his post-battle advice text") + + local game, gbox, state = driveLeader("VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", + "EVENT_BEAT_GIOVANNI") + eq(gbox and gbox.text, Data.text._ViridianGymGiovanniPostBattleAdviceText, + "Giovanni (beaten) shows his farewell text") + + -- ViridianGym.asm .afterBeat: GBFadeOutToBlack, HideObject while the + -- screen is black, GBFadeInFromBlack. Closing the box pushes the shared + -- src/render/Transition fade (not a synchronous hide) -- drive it through + -- its real out/in cycle the way game.stack would. + local Transition = require("src.render.Transition") + local popped = false + game.stack.pop = function() popped = true end + local pushedFade + game.stack.push = function(_, tb) pushedFade = tb end + if gbox then gbox.onDone() end + check(getmetatable(pushedFade) == Transition, + "Giovanni's farewell pushes the shared fade Transition, not a bare hide") + check(not (game.save.objectToggles and game.save.objectToggles.VIRIDIAN_GYM + and game.save.objectToggles.VIRIDIAN_GYM.VIRIDIANGYM_GIOVANNI == false), + "Giovanni is not yet hidden the instant the box closes (still fading out)") + + -- drive through the fade-out; HideObject fires as the onMidpoint + -- callback, exactly when the phase flips from "out" to "in" (the + -- screen is fully black at that instant, matching GBFadeOutToBlack -> + -- HideObject in ViridianGym.asm) + local guard = 0 + while pushedFade.phase == "out" and guard < 10000 do + pushedFade:update(1) + guard = guard + 1 + end + check(game.save.objectToggles and game.save.objectToggles.VIRIDIAN_GYM + and game.save.objectToggles.VIRIDIAN_GYM.VIRIDIANGYM_GIOVANNI == false, + "Giovanni hidden via objectToggles at the fade's midpoint (screen black)") + check(not state.doneCalled, "done() withheld until the fade back in finishes") + + -- drive through the fade-in; onDone (and the stack pop) fire together + -- once it completes, matching GBFadeInFromBlack -> TextScriptEnd + guard = 0 + while not state.doneCalled and guard < 10000 do + pushedFade:update(1) + guard = guard + 1 + end + check(state.doneCalled, "Giovanni's talk chains onDone once the fade back in finishes") + check(popped, "the fade pops itself off the stack when done") + + package.loaded["src.render.TextBox"] = realTB +end + +print(("parity A: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-A assertion(s) failed") end diff --git a/tests/parity_B.lua b/tests/parity_B.lua new file mode 100644 index 00000000..a85ac5d9 --- /dev/null +++ b/tests/parity_B.lua @@ -0,0 +1,107 @@ +-- Parity test, Workstream B. +-- Champions Room -> Hall of Fame cutscene: Oak walk-in/congratulate/ +-- disappoint/come-with-me, warp up into HALL_OF_FAME, then the room script +-- drives the HoF Oak speech + induction (scripts/ChampionsRoom.asm, +-- scripts/HallOfFame.asm). +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions === + +local init = require("data.scripts.init") + +-- (1) both map-script hooks are registered +local hof = init.get("HALL_OF_FAME") +check(hof ~= nil, "HALL_OF_FAME map script registered") +check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a function") + +local champ = init.get("CHAMPIONS_ROOM") +check(champ ~= nil, "CHAMPIONS_ROOM map script registered") +local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL +check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists") + +-- (2) the rival cutscene rows contain the pokered beats in order +rows = rows or {} +local preds = { + { "show_object CHAMPIONSROOM_OAK", + function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end }, + { "move_npc(2,'up',5) OakEntranceAfterVictoryMovement", + function(r) return r[1] == "move_npc" and r[2] == 2 and r[3] == "up" and r[4] == 5 end }, + { "show_text _ChampionsRoomOakDisappointedWithRivalText", + function(r) return r[1] == "show_text" and r[2] == "_ChampionsRoomOakDisappointedWithRivalText" end }, + { "move_npc(2,'up',2) OakExitChampionsRoomMovement", + function(r) return r[1] == "move_npc" and r[2] == 2 and r[3] == "up" and r[4] == 2 end }, + { "hide_object CHAMPIONSROOM_OAK", + function(r) return r[1] == "hide_object" and r[3] == "CHAMPIONSROOM_OAK" end }, + { "warp HALL_OF_FAME", + function(r) return r[1] == "warp" and r[2] == "HALL_OF_FAME" end }, +} +local pi = 1 +for _, r in ipairs(rows) do + if pi <= #preds and preds[pi][2](r) then pi = pi + 1 end +end +for i = 1, #preds do + check(i < pi, "rival cutscene has, in order: " .. preds[i][1]) +end + +-- the induction must NOT run mid-Champions-Room anymore +local hasRecord = false +for _, r in ipairs(rows) do + if r[1] == "record_hall_of_fame" then hasRecord = true end +end +check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame") + +-- (3) Commands.face_player_dir sets the player's facing +local Commands = require("src.script.Commands") +check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function") +local ctx = { overworld = { player = {} } } +Commands.face_player_dir(ctx, "right") +eq(ctx.overworld.player.facing, "right", "face_player_dir sets player.facing") + +-- (4) the two maps warp into each other +local hofMap = Data.maps.HALL_OF_FAME +check(hofMap ~= nil, "Data.maps.HALL_OF_FAME exists") +local hofToChamp = false +for _, w in ipairs(hofMap and hofMap.warps or {}) do + if w.destMap == "CHAMPIONS_ROOM" then hofToChamp = true end +end +check(hofToChamp, "HALL_OF_FAME has a warp back to CHAMPIONS_ROOM") + +local champMap = Data.maps.CHAMPIONS_ROOM +check(champMap ~= nil, "Data.maps.CHAMPIONS_ROOM exists") +local champToHof = false +for _, w in ipairs(champMap and champMap.warps or {}) do + if w.destMap == "HALL_OF_FAME" then champToHof = true end +end +check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME") + +-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and +-- queues (does not directly run) the room cutscene. +local queued +local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end } +local fakeGame = { save = { pendingHallOfFame = true } } +hof.onEnter(fakeGame, fakeOw) +check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set") +eq(fakeGame.save.pendingHallOfFame, false, "HALL_OF_FAME.onEnter clears the one-shot marker") +check(fakeOw.pendingScript ~= nil, "queued script stored on ow.pendingScript") +-- and the queued script drives the room beats +if queued then + local first, last = queued[1], queued[#queued] + check(first[1] == "move_player" and first[2] == "up" and first[3] == 5, + "HoF cutscene starts with move_player up 5") + check(last[1] == "record_hall_of_fame", + "HoF cutscene ends with record_hall_of_fame (induction from the room)") +end +-- a second entry with the marker cleared must NOT replay the induction +queued = nil +fakeGame.save.pendingHallOfFame = false +hof.onEnter(fakeGame, fakeOw) +check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed") + +print(("parity B: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-B assertion(s) failed") end diff --git a/tests/parity_C.lua b/tests/parity_C.lua new file mode 100644 index 00000000..d6ecfd12 --- /dev/null +++ b/tests/parity_C.lua @@ -0,0 +1,286 @@ +-- Parity test, Workstream C. +-- Self-contained: run via `luajit tests/parity_C.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +-- +-- Oracle: pokered/engine/events/elevator.asm (DisplayElevatorFloorMenu), +-- pokered/engine/overworld/elevator.asm (ShakeElevator), +-- pokered/scripts/SilphCoElevator.asm / CeladonMartElevator.asm / +-- RocketHideoutElevator.asm (floor tables), pokered/data/items/names.asm +-- (short FLOOR_* tokens). +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- empty a table in place (rebinding the local wouldn't reach closures +-- that already captured the original table, e.g. ow.startWarpTo below) +local function clear(t) + for i = #t, 1, -1 do t[i] = nil end +end + +local mapScripts = require("data.scripts.init") +local ListMenu = require("src.ui.ListMenu") +local Sound = require("src.core.Sound") +local Map = require("src.world.Map") +local Warp = require("src.world.Warp") + +-- synchronous scriptMove/takeWarp for the walk-out: elevatorWalkOut +-- (data/scripts/story3.lua) rewrites the car's exit warps then walks the +-- player out through the doorway and takes the rewritten warp, instead of +-- the old jump-cut startWarpTo. +local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- the Rocket Hideout keyGate path pushes a TextBox, which needs the font +-- loaded (like tests/run_tests.lua does before any TextBox use) +local Font = require("src.render.Font") +Font.load(Data) + +-- spy on Sound.play so we can see the arrival-SFX beat without real audio +local sfxCalls +local origSoundPlay = Sound.play +Sound.play = function(data, name) + sfxCalls[#sfxCalls + 1] = name + return origSoundPlay(data, name) +end + +-- fake StateStack: just enough for ListMenu:close()/onCancel's game.stack use +local function newStack() + local items = {} + local stack = {} + function stack:push(item) items[#items + 1] = item end + function stack:pop() items[#items] = nil end + function stack:top() return items[#items] end + return stack, items +end + +-- drives one elevator map's onEnter and returns the pushed state (either a +-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox) +local function openElevator(mapId, inventory) + local script = mapScripts.get(mapId) + check(script ~= nil, mapId .. " script registered") + check(script and script.onEnter ~= nil, mapId .. " has onEnter") + local stack, items = newStack() + local warpCalls = {} + local ow = {} + -- the real elevator car map (built without the tile renderer -- Map.new + -- is pure data), plus the player standing on the exit tile they warped + -- in onto (the true arrival cell), so the post-ride walk-out has a + -- door and geometry to work with + local carDef = Data.maps[mapId] + ow.map = Map.new(carDef, Data.tilesets[carDef.tileset]) + local firstWarp = carDef.warps[1] + ow.player = { cellX = firstWarp.x, cellY = firstWarp.y, facing = "up" } + ow.scriptMoves = {} + ow.walkSteps = {} + function ow:startWarpTo(map, x, y, facing) + warpCalls[#warpCalls + 1] = { map = map, x = x, y = y, facing = facing } + end + -- synchronous: advance the entity one step per tile and fire onDone + function ow:scriptMove(entity, dir, tiles, onDone) + local d = DIRVEC[dir] + entity.cellX = entity.cellX + d[1] * tiles + entity.cellY = entity.cellY + d[2] * tiles + entity.facing = dir + self.walkSteps[#self.walkSteps + 1] = dir + if onDone then onDone() end + end + -- resolve the (rewritten) warp entry like OverworldState:takeWarp does + function ow:takeWarp(warpDef) + local destMap, x, y = Warp.destination(Data, warpDef, self.lastOutdoor) + warpCalls[#warpCalls + 1] = + { map = destMap, x = x, y = y, facing = self.player.facing } + end + local game = { + data = Data, + save = { inventory = inventory or {}, player = { name = "RED", rival = "BLUE" } }, + stack = stack, + } + sfxCalls = {} + script.onEnter(game, ow) + return items[#items], warpCalls, stack, ow +end + +-- step the ElevatorShake state (pokered ShakeElevator) frame by frame +-- until it pops itself; returns how many frames the ride took plus the +-- observed scroll-offset trace (first nonzero frame/value, both signs +-- seen). Headless the SFX_SAFARI_ZONE_PA source never sounds, so the +-- .musicLoop wait resolves on the frame after the 100 cycles. +local function rideOut(stack, shake, ow) + local steps, firstFrame, firstOffset = 0, nil, nil + local sawUp, sawDown = false, false + while stack:top() == shake and steps < 400 do + shake:update(1 / 60) + steps = steps + 1 + local o = ow.bgShakeY or 0 + if o ~= 0 and not firstFrame then firstFrame, firstOffset = steps, o end + if o > 0 then sawUp = true elseif o < 0 then sawDown = true end + end + return steps, firstFrame, firstOffset, sawUp and sawDown +end + +local function countSfx(name) + local n = 0 + for _, s in ipairs(sfxCalls) do if s == name then n = n + 1 end end + return n +end + +-- =================================================================== +-- Silph Co elevator: 11 floors, the double-digit sort/label regression +-- =================================================================== +do + local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR") + check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu") + if menu then + eq(#menu.items, 11, "Silph Co elevator lists all 11 floors") + local wantOrder = { "1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F", "11F" } + for i, want in ipairs(wantOrder) do + local item = menu.items[i] + eq(item and item.label, want, "Silph Co floor " .. i .. " label/order") + end + -- labels are short floor tokens, never the full source map id + check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"), + "Silph Co floor label is the short token, not the full map id") + + -- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B -- + -- no warp at all. + clear(warpCalls) + menu.onCancel() + eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)") + + -- Choose a mid-list floor (5F): pokered never warps on the spot -- + -- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the + -- map script runs ShakeElevator: a 12-frame lead-in (the script's + -- Delay3 + ShakeElevator's own 9 frames of Delay3s), then 100 + -- two-frame cycles of hSCY bouncing -1/+1 with SFX_COLLISION each + -- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp. + clear(warpCalls) + sfxCalls = {} + local chosen = menu.items[5] -- "5F" + menu.onChoose(chosen, menu) + eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)") + local shake = stack:top() + check(shake ~= nil and getmetatable(shake) ~= ListMenu and shake.update ~= nil, + "choosing a floor pushes the ElevatorShake state") + local steps, firstFrame, firstOffset, bothWays = rideOut(stack, shake, ow) + eq(steps, 12 + 200 + 1, "Silph ride: 12 lead-in frames + 100 2-frame cycles + PA poll") + eq(firstFrame, 13, "first scroll write lands right after the 12-frame lead-in") + eq(firstOffset, -1, "first hSCY offset is -1 (ld e, $1 then xor $fe)") + check(bothWays, "the scroll oscillates both ways (-1/+1)") + eq(ow.bgShakeY, 0, "hSCY restored to rest after the ride") + eq(countSfx("Collision"), 100, "SFX_COLLISION plays once per shake cycle (ld b, 100)") + eq(countSfx("Safari_Zone_PA"), 1, "SFX_SAFARI_ZONE_PA plays once") + eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "SFX_SAFARI_ZONE_PA caps the ride") + -- .UpdateWarp rewrites the car's exit warp entries to the chosen + -- floor, then the player walks out onto that warp (no jump cut) + eq(ow.map.def.warps[1].destMap, chosen.value.map, + "the car's exit warp is rewritten to the chosen floor's map") + check(#ow.walkSteps >= 1, "the player walks out of the car (scriptMove, not a jump-cut)") + eq(#warpCalls, 1, "the rewritten warp fires exactly once, after the walk-out") + if warpCalls[1] then + eq(warpCalls[1].map, chosen.value.map, "walk-out lands on the chosen floor's map") + eq(warpCalls[1].x, chosen.value.x, "walk-out lands on the chosen floor's x") + eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y") + end + end +end + +-- =================================================================== +-- Celadon Mart elevator: 5 floors, single-digit control (should already +-- have passed lexicographically -- non-regression check) +-- =================================================================== +do + local menu, warpCalls, stack, ow = openElevator("CELADON_MART_ELEVATOR") + check(menu ~= nil and getmetatable(menu) == ListMenu, "CELADON_MART_ELEVATOR opens a ListMenu") + if menu then + eq(#menu.items, 5, "Celadon Mart elevator lists all 5 floors") + local wantOrder = { "1F", "2F", "3F", "4F", "5F" } + for i, want in ipairs(wantOrder) do + eq(menu.items[i] and menu.items[i].label, want, "Celadon Mart floor " .. i .. " label/order") + end + + clear(warpCalls) + menu.onCancel() + eq(#warpCalls, 0, "Celadon Mart cancel does not warp") + + -- CeladonMartElevatorShakeScript farjps straight into ShakeElevator: + -- no extra Delay3, so the lead-in is only ShakeElevator's own 9 frames + clear(warpCalls) + sfxCalls = {} + local chosen = menu.items[3] -- "3F" + menu.onChoose(chosen, menu) + eq(#warpCalls, 0, "Celadon Mart choose does not warp on the spot") + local shake = stack:top() + local steps, firstFrame = rideOut(stack, shake, ow) + eq(steps, 9 + 200 + 1, "Celadon ride: 9 lead-in frames (farjp, no extra Delay3) + shake + PA poll") + eq(firstFrame, 10, "Celadon first scroll write follows the 9-frame lead-in") + eq(countSfx("Collision"), 100, "Celadon Mart shake thuds 100 times") + eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Celadon Mart ride ends on the PA chime") + eq(ow.map.def.warps[1].destMap, chosen.value.map, + "Celadon Mart car exit warp rewritten to the chosen floor") + check(#ow.walkSteps >= 1, "Celadon Mart player walks out (scriptMove, not a jump-cut)") + eq(#warpCalls, 1, "Celadon Mart rewritten warp fires once, after the walk-out") + if warpCalls[1] then + eq(warpCalls[1].map, chosen.value.map, "Celadon Mart walk-out lands on the chosen floor map") + eq(warpCalls[1].x, chosen.value.x, "Celadon Mart walk-out lands on the chosen floor x") + eq(warpCalls[1].y, chosen.value.y, "Celadon Mart walk-out lands on the chosen floor y") + end + end +end + +-- =================================================================== +-- Rocket Hideout elevator: B1F/B2F/B4F ordering (numeric-not-lexical on +-- the digit only), plus the LIFT_KEY gate. +-- =================================================================== +do + -- without the key: text-only, no floor menu + local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {}) + check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something") + check(gated ~= nil and getmetatable(gated) ~= ListMenu, + "Rocket Hideout without LIFT_KEY does not open the floor menu") + + -- with the key: full B1F/B2F/B4F menu, same as the other elevators + local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 }) + check(menu ~= nil and getmetatable(menu) == ListMenu, + "Rocket Hideout with LIFT_KEY opens a ListMenu") + if menu then + eq(#menu.items, 3, "Rocket Hideout elevator lists all 3 floors") + local wantOrder = { "B1F", "B2F", "B4F" } + for i, want in ipairs(wantOrder) do + eq(menu.items[i] and menu.items[i].label, want, "Rocket Hideout floor " .. i .. " label/order") + end + + clear(warpCalls) + menu.onCancel() + eq(#warpCalls, 0, "Rocket Hideout cancel does not warp") + + -- RocketHideoutElevatorShakeScript is `call Delay3 / farcall + -- ShakeElevator` like Silph's: 12 lead-in frames + clear(warpCalls) + sfxCalls = {} + local chosen = menu.items[2] -- "B2F" + menu.onChoose(chosen, menu) + eq(#warpCalls, 0, "Rocket Hideout choose does not warp on the spot") + local shake = stack:top() + local steps = rideOut(stack, shake, ow) + eq(steps, 12 + 200 + 1, "Rocket Hideout ride: 12 lead-in frames + shake + PA poll") + eq(countSfx("Collision"), 100, "Rocket Hideout shake thuds 100 times") + eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Rocket Hideout ride ends on the PA chime") + eq(ow.map.def.warps[1].destMap, chosen.value.map, + "Rocket Hideout car exit warp rewritten to the chosen floor") + check(#ow.walkSteps >= 1, "Rocket Hideout player walks out (scriptMove, not a jump-cut)") + eq(#warpCalls, 1, "Rocket Hideout rewritten warp fires once, after the walk-out") + if warpCalls[1] then + eq(warpCalls[1].map, chosen.value.map, "Rocket Hideout walk-out lands on the chosen floor map") + eq(warpCalls[1].x, chosen.value.x, "Rocket Hideout walk-out lands on the chosen floor x") + eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y") + end + end +end + +Sound.play = origSoundPlay + +print(("parity C: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-C assertion(s) failed") end diff --git a/tests/parity_D.lua b/tests/parity_D.lua new file mode 100644 index 00000000..624e2206 --- /dev/null +++ b/tests/parity_D.lua @@ -0,0 +1,87 @@ +-- Parity test, Workstream D. +-- Self-contained: run via `luajit tests/parity_D.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions per your spec test plan === +-- Gap: SAFARI_STEP_MAPS (OverworldController.lua ~2106-2109) only decremented +-- wSafariSteps on the 4 core zone quadrants; pokered gates step-decrementing +-- purely on EVENT_IN_SAFARI_ZONE (home/overworld.asm:307-310), which stays +-- set through all 9 interior maps (4 quadrants + 4 rest houses + the secret +-- house) and is only cleared back on SAFARI_ZONE_GATE. So all 9 interior +-- maps must decrement; the gate itself must not. + +require("src.render.Font").load(Data) +local OW = require("src.world.OverworldController") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") + +-- safariStep()/safariGameOver()/startWarpTo() close over a module-local +-- `Game` upvalue that is normally set once by OverworldState:enter() during +-- real boot (src/core/Game.lua's Game:load()). The headless test harness +-- never boots a full overworld, so we rewire that shared upvalue directly +-- via the debug library to point at a minimal fake Game -- the same trick +-- for all three closures since they're compiled from the same chunk and +-- must share one Game reference for safariGameOver's side effects (which +-- clears Game.save.safari) to be visible back on our fake save. +local function bindGame(fn, game) + local i = 1 + while true do + local name = debug.getupvalue(fn, i) + if not name then break end + if name == "Game" then debug.setupvalue(fn, i, game); return true end + i = i + 1 + end + return false +end + +StateStack:init() +local fakeGame = { data = Data, stack = StateStack, save = SaveData.newGame() } +check(bindGame(OW.safariStep, fakeGame), "safariStep binds the Game upvalue") +check(bindGame(OW.safariGameOver, fakeGame), "safariGameOver binds the Game upvalue") +check(bindGame(OW.startWarpTo, fakeGame), "startWarpTo binds the Game upvalue") + +local function safariStepOn(mapId) + local fake = setmetatable({ map = { id = mapId } }, { __index = OW }) + return fake:safariStep() +end + +-- All 9 interior Safari Zone maps decrement the step counter. +local COUNTED_MAPS = { + "SAFARI_ZONE_CENTER", "SAFARI_ZONE_EAST", "SAFARI_ZONE_NORTH", "SAFARI_ZONE_WEST", + "SAFARI_ZONE_CENTER_REST_HOUSE", "SAFARI_ZONE_EAST_REST_HOUSE", + "SAFARI_ZONE_NORTH_REST_HOUSE", "SAFARI_ZONE_WEST_REST_HOUSE", + "SAFARI_ZONE_SECRET_HOUSE", +} +for _, mapId in ipairs(COUNTED_MAPS) do + fakeGame.save.safari = { balls = 30, steps = 10 } + local fired = safariStepOn(mapId) + check(not fired, mapId .. " safariStep does not end the game at 10 steps") + eq(fakeGame.save.safari.steps, 9, mapId .. " decrements the safari step counter") +end + +-- The gate (and any non-Safari map) must NOT decrement. +local UNCOUNTED_MAPS = { "SAFARI_ZONE_GATE", "PALLET_TOWN", "FUCHSIA_CITY" } +for _, mapId in ipairs(UNCOUNTED_MAPS) do + fakeGame.save.safari = { balls = 30, steps = 10 } + local fired = safariStepOn(mapId) + check(not fired, mapId .. " safariStep returns false") + 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). +fakeGame.save.safari = { balls = 30, steps = 1 } +local fired = safariStepOn("SAFARI_ZONE_SECRET_HOUSE") +check(fired, "safariStep reports the game-over trigger at 0 steps") +check(fakeGame.save.safari == nil, + "safari session clears (safariGameOver fired) when steps hit 0 in the secret house") + +print(("parity D: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-D assertion(s) failed") end diff --git a/tests/parity_E.lua b/tests/parity_E.lua new file mode 100644 index 00000000..b4e9b9d2 --- /dev/null +++ b/tests/parity_E.lua @@ -0,0 +1,205 @@ +-- Parity test, Workstream E. +-- Self-contained: run via `luajit tests/parity_E.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +-- +-- Covers: Cinnabar Lab fossil revival delay -- scripts/ +-- CinnabarLabFossilRoom.asm (CinnabarLabFossilRoomScientist1Text), +-- engine/events/cinnabar_lab.asm (GiveFossilToCinnabarLab), and +-- scripts/CinnabarIsland.asm line 6 (ResetEvent +-- EVENT_LAB_STILL_REVIVING_FOSSIL on every map load). Deposit a fossil +-- through the fossil-select menu (every carried fossil in FossilsList +-- order, Yes/No confirm, ComeAgainText on either cancel) -> pending for +-- the rest of the visit -> ready once CINNABAR_ISLAND's onEnter has run +-- again -> grant the mon and reset the quest, ported in +-- data/scripts/story2.lua (the talk handler) and data/scripts/story5.lua +-- (M.CINNABAR_ISLAND.onEnter). +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- The real TextBox needs a loaded font plus frame-stepped input to type +-- and dismiss pages -- unrelated to what this workstream verifies (flag / +-- inventory / party state transitions). Stub it to fire its onDone +-- callback immediately, exactly like the real box does once the player +-- has mashed through it, and record the shown strings for the +-- text-content assertions below. opts.choice boxes (SeesFossilText's +-- YesNoChoice) resolve with the scripted `choiceAnswer`. Restored at +-- the end of this file so suites that dofile after this one (the +-- run_tests.lua aggregator) still get the real TextBox. +local realTextBox = package.loaded["src.render.TextBox"] +local shownTexts = {} +local choiceAnswer = true -- scripted YES/NO for opts.choice boxes +package.loaded["src.render.TextBox"] = { + new = function(game, text, onDone, opts) + table.insert(shownTexts, text) + if opts and opts.choice then + opts.choice(choiceAnswer) + elseif onDone then + onDone() + end + return { text = text } + end, +} + +-- The fossil-select menu (GiveFossilToCinnabarLab's bordered list, +-- src/ui/Menu.lua) selects from frame-stepped input; stub it to pick +-- the scripted entry immediately (or back out with B when menuPick is +-- "cancel"), recording the labels for the order assertions. +local realMenu = package.loaded["src.ui.Menu"] +local menuPick = 1 +local menuLabels = nil +package.loaded["src.ui.Menu"] = { + new = function(game, items, opts) + menuLabels = {} + for i, it in ipairs(items) do menuLabels[i] = it.label end + if menuPick == "cancel" then + if opts and opts.onCancel then opts.onCancel() end + else + items[menuPick].onSelect() + end + return {} + end, +} + +local SaveData = require("src.core.SaveData") +local story2 = require("data.scripts.story2") +local story5 = require("data.scripts.story5") + +check(story2.CINNABAR_LAB_FOSSIL_ROOM ~= nil, "CINNABAR_LAB_FOSSIL_ROOM registered") +check(story2.CINNABAR_LAB_FOSSIL_ROOM + and story2.CINNABAR_LAB_FOSSIL_ROOM.talk.TEXT_CINNABARLABFOSSILROOM_SCIENTIST1 ~= nil, + "scientist1 talk handler registered") +check(story5.CINNABAR_ISLAND ~= nil and story5.CINNABAR_ISLAND.onEnter ~= nil, + "CINNABAR_ISLAND.onEnter registered") + +local talkScientist1 = story2.CINNABAR_LAB_FOSSIL_ROOM.talk.TEXT_CINNABARLABFOSSILROOM_SCIENTIST1 + +local function newGame() + local save = SaveData.newGame() + local game = { data = Data, save = save, stack = { push = function() end } } + return game +end + +local function talk(game) + shownTexts = {} + local doneCalled = false + talkScientist1(game, {}, nil, function() doneCalled = true end) + return doneCalled +end + +-- === 1) no fossil in inventory: no-fossils text, no flags set === +do + local game = newGame() + check(talk(game), "no-fossil talk completes") + check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "no fossil: GAVE_FOSSIL_TO_LAB not set") + check(not game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL, "no fossil: STILL_REVIVING not set") + eq(#game.save.party, 0, "no fossil: party unchanged") + eq(shownTexts[#shownTexts], "No! Is too bad!", "no fossil shows NoFossilsText") +end + +-- === 2)-5) full deposit -> pending -> re-entry -> grant cycle === +do + local game = newGame() + game.save.inventory.OLD_AMBER = 1 + + -- 2) depositing OLD_AMBER through the menu + YES confirm: cleared + -- from the bag, quest flags set, AERODACTYL not granted yet (same + -- conversation as the deposit). + menuPick, choiceAnswer = 1, true + check(talk(game), "deposit talk completes") + eq(menuLabels and #menuLabels, 1, "fossil menu lists the one carried fossil") + eq(menuLabels and menuLabels[1], "OLD AMBER", "fossil menu shows the item name") + local sees + for _, s in ipairs(shownTexts) do + if s:find("Resurrection") then sees = s end + end + check(sees and sees:find("OLD AMBER", 1, true) and sees:find("AERODACTYL", 1, true), + "SeesFossilText names both the fossil (wNameBuffer) and the mon (wStringBuffer)") + eq(game.save.inventory.OLD_AMBER, nil, "OLD_AMBER cleared from inventory on deposit") + check(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB == true, "GAVE_FOSSIL_TO_LAB set on deposit") + check(game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL == true, "STILL_REVIVING set on deposit") + eq(#game.save.party, 0, "AERODACTYL not granted in the deposit conversation") + eq(game.save.labFossilMon, "AERODACTYL", "pending species (AERODACTYL) remembered") + + -- 3) re-talking within the same visit: still pending, no grant + check(talk(game), "same-visit re-talk completes") + check(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB == true, + "same-visit re-talk: GAVE_FOSSIL_TO_LAB still set") + check(game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL == true, + "same-visit re-talk: STILL_REVIVING still set") + eq(#game.save.party, 0, "same-visit re-talk: still no mon granted") + eq(shownTexts[#shownTexts], "I take a little\ntime!\fYou go for walk a\nlittle while!", + "same-visit re-talk shows GoForAWalkText") + + -- 4) leaving and re-entering CINNABAR_ISLAND (its onEnter) clears + -- STILL_REVIVING but leaves GAVE_FOSSIL_TO_LAB set + story5.CINNABAR_ISLAND.onEnter(game, {}) + check(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB == true, + "CINNABAR_ISLAND onEnter: GAVE_FOSSIL_TO_LAB survives the reload") + check(not game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL, + "CINNABAR_ISLAND onEnter clears STILL_REVIVING") + + -- 5) talking again grants AERODACTYL at level 30 and resets the whole + -- quest (all three EVENT_ flags, and the transient species field) so + -- a second fossil can be deposited later + check(talk(game), "ready talk completes") + eq(#game.save.party, 1, "AERODACTYL granted into the party after re-entry") + eq(game.save.party[1] and game.save.party[1].species, "AERODACTYL", + "granted species is AERODACTYL") + eq(game.save.party[1] and game.save.party[1].level, 30, "AERODACTYL granted at level 30") + check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "grant resets GAVE_FOSSIL_TO_LAB") + check(not game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL, "grant resets STILL_REVIVING") + check(not game.save.flags.EVENT_LAB_HANDING_OVER_FOSSIL_MON, "grant resets HANDING_OVER_FOSSIL_MON") + eq(game.save.labFossilMon, nil, "pending species cleared after grant") +end + +-- === the menu lists every carried fossil in FossilsList scan order +-- (DOME_FOSSIL, HELIX_FOSSIL, OLD_AMBER) and deposits the chosen one === +do + local game = newGame() + game.save.inventory.OLD_AMBER = 1 + game.save.inventory.HELIX_FOSSIL = 1 + menuPick, choiceAnswer = 1, true + check(talk(game), "multi-fossil deposit talk completes") + eq(menuLabels and #menuLabels, 2, "menu lists both carried fossils") + eq(menuLabels and menuLabels[1], "HELIX FOSSIL", "HELIX FOSSIL listed first (FossilsList order)") + eq(menuLabels and menuLabels[2], "OLD AMBER", "OLD AMBER listed second") + eq(game.save.labFossilMon, "OMANYTE", "choosing HELIX FOSSIL deposits it") + eq(game.save.inventory.HELIX_FOSSIL, nil, "HELIX_FOSSIL cleared from inventory") + eq(game.save.inventory.OLD_AMBER, 1, "OLD_AMBER left untouched for a later visit") +end + +-- === backing out of the menu with B: ComeAgainText, nothing taken +-- (GiveFossilToCinnabarLab .cancelledGivingFossil) === +do + local game = newGame() + game.save.inventory.DOME_FOSSIL = 1 + menuPick = "cancel" + check(talk(game), "menu-cancel talk completes") + eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!", "menu B-out shows ComeAgainText") + eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after menu cancel") + check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "menu cancel sets no quest flags") + eq(game.save.labFossilMon, nil, "menu cancel leaves no pending species") +end + +-- === answering NO on the SeesFossilText confirm: same cancel path === +do + local game = newGame() + game.save.inventory.DOME_FOSSIL = 1 + menuPick, choiceAnswer = 1, false + check(talk(game), "confirm-NO talk completes") + eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!", "NO on the confirm shows ComeAgainText") + eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after NO") + check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "NO sets no quest flags") + eq(game.save.labFossilMon, nil, "NO leaves no pending species") +end + +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.Menu"] = realMenu + +print(("parity E: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-E assertion(s) failed") end diff --git a/tests/parity_F.lua b/tests/parity_F.lua new file mode 100644 index 00000000..cb70a315 --- /dev/null +++ b/tests/parity_F.lua @@ -0,0 +1,94 @@ +-- Parity test, Workstream F. +-- Self-contained: run via `luajit tests/parity_F.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +-- +-- Covers: Oak no longer hands over 5 POKé BALLs the instant a starter is +-- picked (scripts/OaksLab.asm has no such grant); the balls are handed +-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on +-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot +-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua). +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.PALLET_TOWN) then Data:load() end + +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local Flags = require("src.script.Flags") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +-- pumps a script coroutine to completion, mashing A through any +-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's +-- runScript helper for the parcel/pokedex chain) +local function runScript(script) + local r = ScriptRunner.new(Game, nil) + r:run(script, {}) + local guard = 0 + while r:isRunning() and guard < 2000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +-- === 1) picking a starter no longer grants POKé BALLs === +-- (like the parcel-chain runScript below, this runner has no live +-- overworld; the starterBall() script's give_pokemon/set_flag rows run +-- fine, but its later rival-counterpick NPC choreography needs +-- ctx.overworld and errors out headless -- the ScriptRunner logs and +-- kills the coroutine, so isRunning() still goes false, which is all we +-- need to check the pre-crash flag/inventory state below) +Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB") +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_BALL")), + "starter pick script completes") +check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set") +eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter") + +-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs === +check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), + "mart clerk script completes") +eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") + +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), + "Oak delivery script completes") +eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered") +check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set") +check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set") +eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat") + +-- talking to Oak again before beating the Route 22 rival should fall +-- into the RaiseYourYoungPokemon branch, not give balls +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), + "Oak talk (pre-Route22-win) script completes") +eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs before the Route 22 rival is beaten") + +-- === 3) beating the Route 22 rival unlocks the real grant === +Flags.set(Game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE") +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), + "Oak give-balls script completes") +eq(Game.save.inventory.POKE_BALL, 5, "Oak gives 5 POKe Balls after the Route 22 win") +check(Flags.get(Game.save, "EVENT_GOT_POKEBALLS_FROM_OAK"), "one-shot flag set") + +-- === 4) talking to Oak again does not re-grant (one-shot gate) === +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), + "Oak talk (post-grant) script completes") +eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk") + +print(("parity F: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-F assertion(s) failed") end diff --git a/tests/parity_G.lua b/tests/parity_G.lua new file mode 100644 index 00000000..24945138 --- /dev/null +++ b/tests/parity_G.lua @@ -0,0 +1,58 @@ +-- Parity test, Workstream G. +-- Covers: spinner arrow tile animation. pokered's +-- engine/overworld/spinners.asm LoadSpinnerArrowTiles VRAM-patches 4 fixed +-- tile IDs per tileset (Gym/Facility), flickering between the blur graphic +-- (gfx/overworld/spinners.2bpp) and the tileset's own static graphic once +-- per forced-movement step while wMovementFlags.BIT_SPINNING is set. This +-- checks (a) TileRenderer.SPINNER_ARROW_TILES ids are real walkable tile +-- ids on the GYM/FACILITY tilesets (data/tilesets/spinner_tiles.asm), and +-- (b) the headless-safe setSpinning()/spinBlurActive() toggle behavior. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local TileRenderer = require("src.render.TileRenderer") + +-- === (a) SPINNER_ARROW_TILES ids are walkable metatile ids on the real +-- tilesets (data/tilesets/spinner_tiles.asm dest tile ids) === +local function idInList(id, list) + for _, v in ipairs(list) do + if v == id then return true end + end + return false +end + +check(TileRenderer.SPINNER_ARROW_TILES ~= nil, "TileRenderer exports SPINNER_ARROW_TILES") +if TileRenderer.SPINNER_ARROW_TILES then + local gymWalkable = Data.tilesets.GYM.walkable + for _, id in ipairs(TileRenderer.SPINNER_ARROW_TILES.GYM) do + check(idInList(id, gymWalkable), + ("GYM spinner tile 0x%x is walkable"):format(id)) + end + local facilityWalkable = Data.tilesets.FACILITY.walkable + for _, id in ipairs(TileRenderer.SPINNER_ARROW_TILES.FACILITY) do + check(idInList(id, facilityWalkable), + ("FACILITY spinner tile 0x%x is walkable"):format(id)) + end +end + +-- === (b) setSpinning()/spinBlurActive() toggle (headless-safe: pure +-- state, no love.image dependency) === +TileRenderer.setSpinning(false) +check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin") + +TileRenderer.setSpinning(true) +local a = TileRenderer.spinBlurActive() +for i = 1, 8 do TileRenderer.tick() end +local b = TileRenderer.spinBlurActive() +check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning") + +TileRenderer.setSpinning(false) +check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends") + +print(("parity G: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-G assertion(s) failed") end diff --git a/tests/parity_H.lua b/tests/parity_H.lua new file mode 100644 index 00000000..f3e45eaa --- /dev/null +++ b/tests/parity_H.lua @@ -0,0 +1,192 @@ +-- Parity test, Workstream H. +-- Covers: Seafoam B2F boulder cascade. field.py now parses +-- scripts/SeafoamIslands1F.asm / SeafoamIslandsB1F.asm the same way it +-- already parsed B2F/B3F/B4F, wiring SEAFOAM_ISLANDS_1F.holes -> +-- SEAFOAM_ISLANDS_B1F and SEAFOAM_ISLANDS_B1F.holes -> SEAFOAM_ISLANDS_B2F. +-- data/scripts/seafoam.lua's onEnter hack that force-showed the B2F +-- boulders is gone; the generic OverworldState:boulderIntoHole (driven by +-- this data) now reveals every floor's plug boulders only once the +-- boulder above them actually falls through its hole. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === (1) static extraction assertions === +local sf = Data.field.seafoam +check(sf.SEAFOAM_ISLANDS_1F ~= nil, "field.seafoam has a SEAFOAM_ISLANDS_1F entry") +check(sf.SEAFOAM_ISLANDS_B1F ~= nil, "field.seafoam has a SEAFOAM_ISLANDS_B1F entry") +if sf.SEAFOAM_ISLANDS_1F then + eq(#sf.SEAFOAM_ISLANDS_1F.holes, 2, "SEAFOAM_ISLANDS_1F has 2 holes") + eq(sf.SEAFOAM_ISLANDS_1F.holeDestination, "SEAFOAM_ISLANDS_B1F", + "SEAFOAM_ISLANDS_1F holes drop to B1F") + local h1 = sf.SEAFOAM_ISLANDS_1F.holes[1] + eq(h1.boulderEvent, "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", "1F hole 1 boulder event") + eq(h1.hideObject, "TOGGLE_SEAFOAM_ISLANDS_1F_BOULDER_1", "1F hole 1 hides 1F boulder 1") + eq(h1.showObject, "TOGGLE_SEAFOAM_ISLANDS_B1F_BOULDER_1", "1F hole 1 shows B1F boulder 1") +end +if sf.SEAFOAM_ISLANDS_B1F then + eq(#sf.SEAFOAM_ISLANDS_B1F.holes, 2, "SEAFOAM_ISLANDS_B1F has 2 holes") + eq(sf.SEAFOAM_ISLANDS_B1F.holeDestination, "SEAFOAM_ISLANDS_B2F", + "SEAFOAM_ISLANDS_B1F holes drop to B2F") + local h1 = sf.SEAFOAM_ISLANDS_B1F.holes[1] + eq(h1.boulderEvent, "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE", "B1F hole 1 boulder event") + eq(h1.hideObject, "TOGGLE_SEAFOAM_ISLANDS_B1F_BOULDER_1", "B1F hole 1 hides B1F boulder 1") + eq(h1.showObject, "TOGGLE_SEAFOAM_ISLANDS_B2F_BOULDER_1", "B1F hole 1 shows B2F boulder 1") +end +-- the pre-existing B3F edge (already ported) must be untouched +check(sf.SEAFOAM_ISLANDS_B3F and #sf.SEAFOAM_ISLANDS_B3F.holes == 2, + "SEAFOAM_ISLANDS_B3F still has its own 2 holes (B3F->B4F, unrelated to this change)") + +-- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry +local seafoamScripts = require("data.scripts.seafoam") +check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil, + "data/scripts/seafoam.lua no longer hardcodes a SEAFOAM_ISLANDS_B2F onEnter hook") + +-- === (2) functional 1F -> B1F -> B2F -> B3F -> B4F cascade === +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() +Game.save = SaveData.newGame() +Game.save.flags = {} +Game.save.objectToggles = nil + +local function objOf(mapId, name) + for _, o in ipairs(Data.maps[mapId].objects) do + if o.name == name then return o end + end +end + +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "SEAFOAM_ISLANDS_1F", 1, 1, "down") +local ow = Game.stack:top() +check(ow ~= nil and ow.map ~= nil and ow.map.id == "SEAFOAM_ISLANDS_1F", + "pushed OverworldController headlessly onto SEAFOAM_ISLANDS_1F") + +-- baseline: 1F boulders start visible (toggle ON), B1F/B2F ones start +-- hidden (data/maps/toggleable_objects.asm:398-409) since nothing has +-- fallen through a hole yet +check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_1F", + objOf("SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1")), + "1F boulder 1 starts visible") +check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_1F", + objOf("SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2")), + "1F boulder 2 starts visible") +for _, name in ipairs({ "SEAFOAMISLANDSB1F_BOULDER1", "SEAFOAMISLANDSB1F_BOULDER2" }) do + check(not OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B1F", objOf("SEAFOAM_ISLANDS_B1F", name)), + "B1F " .. name .. " starts hidden") +end +for _, name in ipairs({ "SEAFOAMISLANDSB2F_BOULDER1", "SEAFOAMISLANDSB2F_BOULDER2" }) do + check(not OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B2F", objOf("SEAFOAM_ISLANDS_B2F", name)), + "B2F " .. name .. " starts hidden") +end + +-- Fall order, top to bottom. Each entry pushes a synthesized boulder npc +-- onto a hole cell and checks the hide/show/event side effects. The +-- destMap/name pairs and hole coords come from the oracle refs (see the +-- workstream H spec): SeafoamIslands1F.asm/SeafoamIslandsB1F.asm (the new +-- wiring) and the pre-existing SeafoamIslandsB2F.asm/B3F.asm (already +-- ported, kept here as a regression check of the full chain). +-- +-- Note: the B2F -> B3F leg's *destination* visibility (steps 5-6 below) +-- is a known pre-existing gap unrelated to this workstream: B3F's +-- toggleable_objects.asm ordinal skips BOULDER1/BOULDER4, so +-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4 (which should land on +-- SEAFOAMISLANDSB3F_BOULDER5/6) resolve through +-- OverworldController.lua's toggleToObjectName() to the wrong (already- +-- visible) BOULDER3/4 instead. That resolver lives outside this +-- workstream's port targets, so only the event flag + source-hide (both +-- correct today) are asserted for that leg; the cosmetic destination +-- reveal is left as-is. +local pushes = { + { curMap = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6, + event = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_1F", srcName = "SEAFOAMISLANDS1F_BOULDER1", + dstMap = "SEAFOAM_ISLANDS_B1F", dstName = "SEAFOAMISLANDSB1F_BOULDER1", + checkDst = true }, + { curMap = "SEAFOAM_ISLANDS_1F", hx = 24, hy = 6, + event = "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_1F", srcName = "SEAFOAMISLANDS1F_BOULDER2", + dstMap = "SEAFOAM_ISLANDS_B1F", dstName = "SEAFOAMISLANDSB1F_BOULDER2", + checkDst = true }, + { curMap = "SEAFOAM_ISLANDS_B1F", hx = 18, hy = 6, + event = "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B1F", srcName = "SEAFOAMISLANDSB1F_BOULDER1", + dstMap = "SEAFOAM_ISLANDS_B2F", dstName = "SEAFOAMISLANDSB2F_BOULDER1", + checkDst = true }, + { curMap = "SEAFOAM_ISLANDS_B1F", hx = 23, hy = 6, + event = "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B1F", srcName = "SEAFOAMISLANDSB1F_BOULDER2", + dstMap = "SEAFOAM_ISLANDS_B2F", dstName = "SEAFOAMISLANDSB2F_BOULDER2", + checkDst = true }, + { curMap = "SEAFOAM_ISLANDS_B2F", hx = 19, hy = 6, + event = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER1", + dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER5", + checkDst = false }, + { curMap = "SEAFOAM_ISLANDS_B2F", hx = 22, hy = 6, + event = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER2", + dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER6", + checkDst = false }, + { curMap = "SEAFOAM_ISLANDS_B3F", hx = 3, hy = 16, + event = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B3F", srcName = "SEAFOAMISLANDSB3F_BOULDER1", + dstMap = "SEAFOAM_ISLANDS_B4F", dstName = "SEAFOAMISLANDSB4F_BOULDER1", + checkDst = true }, + { curMap = "SEAFOAM_ISLANDS_B3F", hx = 6, hy = 16, + event = "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE", + srcMap = "SEAFOAM_ISLANDS_B3F", srcName = "SEAFOAMISLANDSB3F_BOULDER2", + dstMap = "SEAFOAM_ISLANDS_B4F", dstName = "SEAFOAMISLANDSB4F_BOULDER2", + checkDst = true }, +} + +for i, p in ipairs(pushes) do + if ow.map.id ~= p.curMap then + ow:setMap(p.curMap, 1, 1, "down") + end + local npc = { cellX = p.hx, cellY = p.hy, def = { name = p.srcName } } + table.insert(ow.npcs, npc) + table.insert(ow.entities, npc) + local ok = ow:boulderIntoHole(npc) + check(ok, ("push %d: boulderIntoHole(%s) returns true"):format(i, p.srcName)) + check(Game.save.flags[p.event] == true, + ("push %d: %s is set"):format(i, p.event)) + check(not OW.objectVisible(Game.save, p.srcMap, objOf(p.srcMap, p.srcName)), + ("push %d: source %s is now hidden"):format(i, p.srcName)) + if p.checkDst then + check(OW.objectVisible(Game.save, p.dstMap, objOf(p.dstMap, p.dstName)), + ("push %d: destination %s is now visible"):format(i, p.dstName)) + end +end + +-- === end state: the known plug/current end state, now reached via the +-- full 1F -> B4F chain instead of starting mid-way at B2F === +check(Game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE == true + and Game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE == true, + "both EVENT_SEAFOAM3_BOULDER{1,2}_DOWN_HOLE are set") +check(Game.save.flags.EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE == true + and Game.save.flags.EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE == true, + "both EVENT_SEAFOAM4_BOULDER{1,2}_DOWN_HOLE are set") +check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F", + objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1")), + "SEAFOAMISLANDSB4F_BOULDER1 is visible at the end state") +check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F", + objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")), + "SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state") + +print(("parity H: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-H assertion(s) failed") end diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua new file mode 100644 index 00000000..9c11fdc8 --- /dev/null +++ b/tests/parity_I_M.lua @@ -0,0 +1,404 @@ +-- Parity test, Workstream I+M. +-- I: Surf/Cut are chosen from the party menu's per-mon field-move submenu +-- (start_sub_menus.asm .outOfBattleMovePointers), never from an +-- overworld A-press. M: Strength gates boulder pushing on a session +-- "activated" flag (push_boulder.asm TryPushingBoulder reads +-- BIT_STRENGTH_ACTIVE) set only by the party-menu STRENGTH action and +-- cleared on every real map load (home/overworld.asm EnterMap -> +-- ResetUsingStrengthOutOfBattleBit). +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === harness === +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local PartyMenu = require("src.ui.PartyMenu") +local TextBox = require("src.render.TextBox") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() + +-- Spy on the TextBox constructor so we can assert which message a field +-- move surfaced (the raw text passed in, before {PLAYER}/token expansion). +local realTextBoxNew = TextBox.new +local captured = {} +TextBox.new = function(game, text, onDone, opts) + captured[#captured + 1] = text or "" + return realTextBoxNew(game, text, onDone, opts) +end +local function clearCaptured() captured = {} end +local function sawText(sub) + for _, t in ipairs(captured) do if t:find(sub, 1, true) then return true end end + return false +end + +-- one input frame: btns is a list of GB button names held this frame. +-- Sets both the edge (wasPressed) and held (isDown) states so TextBoxes +-- type at the held-button fast speed while menus still see the edge. +local function frame(btns) + Input.pressed = {} + for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end + StateStack:update(1 / 60) + for _, b in ipairs(btns or {}) do Input.state[b] = false end +end +local function popAll() while Game.stack:top() do Game.stack:pop() end end +local function popToOW() while Game.stack:top() and Game.stack:top() ~= OW do Game.stack:pop() end end +local function pushOW(mapId, x, y, facing) + popAll() + Game.stack:push(OW, mapId, x, y, facing) + return Game.stack:top() +end +-- drain any TextBox(es) sitting on top of the overworld (OW has no .pages) +local function drainText() + local guard = 0 + while guard < 400 do + guard = guard + 1 + local top = Game.stack:top() + if not top or top.pages == nil then break end + frame({ "a" }) + end +end +local function onStack(s) + for _, st in ipairs(Game.stack.states) do if st == s then return true end end + return false +end +local function submenuActions(pm) + local s = {} + for _, it in ipairs(pm.subItems or {}) do s[it.action] = true end + return s +end +-- a party mon that knows exactly the given field move(s) +local function mkMon(species, ...) + local m = Pokemon.new(Data, species, 20) + m.moves = {} + for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end + return m +end +-- open the field-move submenu and select the entry at row `idx` +local function selectSubItem(pm, idx) + Game.stack:push(pm) + frame({ "a" }) -- A on the mon builds the submenu + for _ = 2, idx do frame({ "down" }) end + frame({ "a" }) -- A on the target row dispatches +end + +-- =========================================================================== +-- M: STRENGTH activation + boulder gate (SEAFOAM_ISLANDS_1F boulder @18,10) +-- =========================================================================== +Game.save.party = { mkMon("MACHOP", "STRENGTH") } +Game.save.inventory = { RAINBOWBADGE = true } +local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") + +eq(ow.strengthActive, false, "setMap default: strengthActive is false") +local boulder = ow:npcAtCell(18, 10) +check(boulder ~= nil and boulder.def.sprite == "SPRITE_BOULDER", + "boulder NPC present at (18,10)") +check(ow:partyKnows("STRENGTH") ~= nil, "party knows STRENGTH + holds RAINBOWBADGE") + +-- Gap under test: knowing STRENGTH + badge is NOT enough; without an +-- activation the push routine bails at the BIT_STRENGTH_ACTIVE gate. +eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 1)") +eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 2)") +eq(boulder.cellX, 18, "boulder unmoved while STRENGTH is inactive") + +-- activate via the party menu STRENGTH action (submenu {STATS,SWITCH,STRENGTH}) +clearCaptured() +local pmStr = PartyMenu.new(Game) +selectSubItem(pmStr, 3) +eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive") +check(not onStack(pmStr), "party menu closes after STRENGTH") +check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown") +drainText() +check(sawText("move boulders"), "_CanMoveBouldersText shown after it") + +-- now the same two bumps push the boulder (gate passes -> arm -> move) +eq(ow:checkBoulderPush("right"), false, "first bump arms the push after activation") +eq(ow:checkBoulderPush("right"), true, "boulder pushes after activation") + +-- every real map load clears the flag (ResetUsingStrengthOutOfBattleBit) +ow:setMap("SEAFOAM_ISLANDS_1F", 17, 10, "right") +eq(ow.strengthActive, false, "setMap re-entry resets strengthActive") +eq(ow:checkBoulderPush("right"), false, + "no push after map reload until STRENGTH is reselected") + +-- =========================================================================== +-- I: SURF from the party menu (PALLET_TOWN water @4,14, stand @4,13) +-- =========================================================================== +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +ow = pushOW("PALLET_TOWN", 4, 13, "down") +ow.player.surfing = false + +eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok when facing water") +ow.player.facing = "up" +eq(ow:useSurfFieldMove(), "no_water", "useSurfFieldMove no_water when facing land") +ow.player.facing = "down" +Game.save.inventory.SOULBADGE = nil +eq(ow:useSurfFieldMove(), "no_badge", "useSurfFieldMove no_badge without SOULBADGE") +Game.save.inventory.SOULBADGE = true + +-- failure path: selecting SURF while not facing water shows +-- _NoSurfingHereText and loops back (submenu stays open, no mount) +ow.player.facing = "up"; ow.player.surfing = false +clearCaptured() +local pmSurfFail = PartyMenu.new(Game) +selectSubItem(pmSurfFail, 3) +check(sawText("No SURFing"), "_NoSurfingHereText when not facing water") +check(pmSurfFail.submenu == true, "party menu stays open after a failed SURF") +eq(ow.player.surfing, false, "no mount when SURF fails") +popToOW() + +-- success path: facing water -> mount + _SurfingGotOnText, menu closes +ow.player.facing = "down"; ow.player.surfing = false +clearCaptured() +local pmSurf = PartyMenu.new(Game) +selectSubItem(pmSurf, 3) +eq(ow.player.surfing, true, "SURF from the party menu sets player.surfing") +check(not onStack(pmSurf), "party menu closes after a successful SURF") +check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF") + +-- =========================================================================== +-- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge +-- =========================================================================== +Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") } +Game.save.inventory = {} +ow = pushOW("PALLET_TOWN", 4, 13, "down") +local pmNoBadge = PartyMenu.new(Game) +Game.stack:push(pmNoBadge) +frame({ "a" }) +local actsOff = submenuActions(pmNoBadge) +check(not actsOff.cut and not actsOff.surf and not actsOff.strength, + "no CUT/SURF/STRENGTH submenu entries without the required badges") +popToOW() +Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true } +local pmBadge = PartyMenu.new(Game) +Game.stack:push(pmBadge) +frame({ "a" }) +local actsOn = submenuActions(pmBadge) +check(actsOn.cut and actsOn.surf and actsOn.strength, + "CUT/SURF/STRENGTH submenu entries appear once the badges are held") + +-- =========================================================================== +-- I: CUT from the party menu (CERULEAN_CITY cut tree @18,28, stand @17,28) +-- =========================================================================== +Game.save.party = { mkMon("BULBASAUR", "CUT") } +Game.save.inventory = { CASCADEBADGE = true } +ow = pushOW("CERULEAN_CITY", 17, 28, "right") +check(ow.map:blockAt(9, 14) == 50, "cut tree block (50) present before CUT") + +eq(ow:useCutFieldMove(), "ok", "useCutFieldMove ok when facing a cut tree") +ow.player.facing = "up" +eq(ow:useCutFieldMove(), "nothing", "useCutFieldMove nothing when not facing a tree") +ow.player.facing = "right" +Game.save.inventory.CASCADEBADGE = nil +eq(ow:useCutFieldMove(), "no_badge", "useCutFieldMove no_badge without CASCADEBADGE") +Game.save.inventory.CASCADEBADGE = true + +-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced +clearCaptured() +local pmCut = PartyMenu.new(Game) +selectSubItem(pmCut, 3) +check(not onStack(pmCut), "party menu closes after a successful CUT") +check(sawText("CUT"), "_UsedCutText shown on a successful CUT") +drainText() -- the tree swap is deferred until the message is dismissed +eq(ow.map:blockAt(9, 14), 109, "CUT replaces the tree block (50 -> 109)") + +-- failure path: not facing a tree -> _NothingToCutText, submenu stays open +popToOW() +ow.player.facing = "up" +clearCaptured() +local pmCutFail = PartyMenu.new(Game) +selectSubItem(pmCutFail, 3) +check(sawText("anything to CUT"), "_NothingToCutText when not facing a tree") +check(pmCutFail.submenu == true, "party menu stays open after a failed CUT") +ow.player.facing = "right" + +-- =========================================================================== +-- I: the overworld A-press shortcut for Surf/Cut is gone (interact()) +-- =========================================================================== +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +ow = pushOW("PALLET_TOWN", 4, 13, "down") +ow.player.surfing = false +ow:interact() +eq(ow.player.surfing, false, "interact() facing water no longer starts Surf") + +Game.save.party = { mkMon("BULBASAUR", "CUT") } +Game.save.inventory = { CASCADEBADGE = true } +ow = pushOW("CERULEAN_CITY", 17, 28, "right") +local cutBlk0 = ow.map:blockAt(9, 14) +ow:interact() +eq(cutBlk0, 50, "cut tree still present before interact()") +eq(ow.map:blockAt(9, 14), 50, "interact() facing a cut tree no longer starts Cut") + +-- =========================================================================== +-- I: IsSurfingAllowed (engine/overworld/field_move_messages.asm) -- the +-- Cycling Road and Seafoam B4F current refusals +-- =========================================================================== +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +ow = pushOW("PALLET_TOWN", 4, 13, "down") +ow.player.surfing = false + +-- BIT_ALWAYS_ON_BIKE set -> refuse with _CyclingIsFunText, submenu stays +Game.save.forcedBike = true +eq(ow:useSurfFieldMove(), "forced_bike", "forced bike refuses SURF (even facing water)") +clearCaptured() +local pmBike = PartyMenu.new(Game) +selectSubItem(pmBike, 3) +check(sawText("Cycling is fun!\nForget SURFing!"), "_CyclingIsFunText verbatim") +check(pmBike.submenu == true, "party menu stays open (.loop) after the bike refusal") +eq(ow.player.surfing, false, "no mount on the Cycling Road") +Game.save.forcedBike = nil +popToOW() + +-- the flag's lifecycle: armed by the forced-bike tiles +-- (CheckForceBikeOrSurf), cleared by the Route 16/18 gate scripts and the +-- fly/dungeon/blackout warps (HandleFlyWarpOrDungeonWarp) +Game.save.inventory.BICYCLE = 1 +Game.save.onBike = false +ow = pushOW("ROUTE_16", 17, 10, "down") +ow:checkForcedMovement() +eq(Game.save.onBike, true, "forced-bike tile mounts the BICYCLE") +eq(Game.save.forcedBike, true, "forced-bike tile arms BIT_ALWAYS_ON_BIKE") +drainText() +ow:setMap("ROUTE_16_GATE_1F", 4, 8, "down") +eq(Game.save.forcedBike, nil, "Route 16 gate clears BIT_ALWAYS_ON_BIKE") +Game.save.forcedBike = true +ow:setMap("ROUTE_18_GATE_1F", 4, 8, "down") +eq(Game.save.forcedBike, nil, "Route 18 gate clears BIT_ALWAYS_ON_BIKE") +Game.save.forcedBike = true +ow = pushOW("ROUTE_17", 4, 10, "down") +ow:flyTo("PALLET_TOWN") +eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE") +ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo +Game.save.forcedBike = true +ow:warpToHealPoint() +eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE") +ow.transitioning = false -- undo the queued warp transition +Game.save.onBike = false +Game.save.inventory.BICYCLE = nil + +-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only +-- until BOTH boulders are down (CheckBothEventsSet) +Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil +Game.save.flags["EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"] = nil +ow = pushOW("SEAFOAM_ISLANDS_B4F", 7, 11, "down") +ow.player.surfing = false +check(ow.map:isWaterCell(7, 12), "water south of the B4F stairs square") +eq(ow:useSurfFieldMove(), "current", "B4F stairs square refuses SURF pre-boulders") +clearCaptured() +local pmCur = PartyMenu.new(Game) +selectSubItem(pmCur, 3) +check(sawText("The current is\nmuch too fast!"), "_CurrentTooFastText verbatim") +check(pmCur.submenu == true, "party menu stays open (.loop) after the current refusal") +eq(ow.player.surfing, false, "no mount against the current") +popToOW() +ow.player.cellX, ow.player.cellY = 7, 10 +eq(ow:useSurfFieldMove(), "no_water", "one square north the gate doesn't fire") +ow.player.cellX, ow.player.cellY = 7, 11 +Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = true +eq(ow:useSurfFieldMove(), "current", "one boulder down still refuses (both required)") +Game.save.flags["EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"] = true +eq(ow:useSurfFieldMove(), "ok", "both boulders down: SURF allowed from the stairs") +Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil +Game.save.flags["EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"] = nil + +-- =========================================================================== +-- I: SURF re-selected while surfing (ItemUseSurfboard .tryToStopSurfing) +-- =========================================================================== +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +ow = pushOW("PALLET_TOWN", 4, 14, "up") +ow.player.surfing = true +eq(ow:useSurfFieldMove(), "dismount", "surfing + facing land tries to get off") +ow.player.facing = "down" +eq(ow:useSurfFieldMove(), "no_place", "surfing + facing open water: no place") +ow.player.facing = "up" +table.insert(ow.entities, { cellX = 4, cellY = 13 }) +eq(ow:useSurfFieldMove(), "no_place", + "a sprite on the landing square blocks it (IsSpriteInFrontOfPlayer2)") +table.remove(ow.entities) + +-- menu-driven dismount: NO text, the menu closes behind the white blink +-- (.stopSurfing never prints; wActionResult stays 1 -> whiteout + +-- .goBackToMap) and the simulated pad press steps the player ashore +clearCaptured() +local pmOff = PartyMenu.new(Game) +selectSubItem(pmOff, 3) +check(not onStack(pmOff), "party menu closes on dismount") +eq(ow.player.surfing, false, ".stopSurfing returns to walking before the step") +eq(#captured, 0, "no message on a successful dismount") +local offFlash = Game.stack:top() +check(offFlash ~= nil and offFlash ~= ow and offFlash.pages == nil, + "the GBPalWhiteOut blink covers the menu close") +for _ = 1, 60 do frame({}) end -- blink pops, the queued step walks out +eq(Game.stack:top(), ow, "back on the map after the blink") +eq(ow.player.cellY, 13, "the player stepped forward onto land") + +-- "no place to get off": the text shows, and the menu STILL closes +-- (.cannotStopSurfing leaves wActionResultOrTookBattleTurn at 1) +ow.player.surfing = true +ow.player.cellX, ow.player.cellY = 4, 15 +ow.player.px, ow.player.py = 4 * 16, 15 * 16 +ow.player.facing = "down" +clearCaptured() +local pmNoOff = PartyMenu.new(Game) +selectSubItem(pmNoOff, 3) +check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim") +check(not onStack(pmNoOff), "the menu closes after the message (result stays 1)") +eq(ow.player.surfing, true, "still surfing after a blocked dismount") +drainText() +ow.player.surfing = false +popToOW() + +-- =========================================================================== +-- M: STRENGTH pages -- no prompt on _UsedStrengthText (text_asm cry + +-- Delay3 auto-advance), `prompt` on _CanMoveBouldersText, then the +-- GBPalWhiteOutWithDelay3 blink (start_sub_menus.asm .strength) +-- =========================================================================== +Game.save.party = { mkMon("MACHOP", "STRENGTH") } +Game.save.inventory = { RAINBOWBADGE = true } +ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") +clearCaptured() +local pmStr2 = PartyMenu.new(Game) +selectSubItem(pmStr2, 3) +local page1 = Game.stack:top() +check(page1 ~= nil and page1.pages ~= nil and page1.auto ~= nil, + "_UsedStrengthText box is a no-prompt (auto) page") +local guard = 0 +while Game.stack:top() == page1 and guard < 240 do guard = guard + 1; frame({}) end +check(Game.stack:top() ~= page1, "page 1 advanced without an A press") +check(sawText("can\nmove boulders."), "_CanMoveBouldersText follows") +local page2 = Game.stack:top() +check(page2 ~= nil and page2.pages ~= nil and page2.auto == nil, + "_CanMoveBouldersText is a normal `prompt` page") +for _ = 1, 150 do frame({}) end -- types out, then waits +check(Game.stack:top() == page2, "page 2 waits for A/B") +frame({ "a" }) +local strFlash = Game.stack:top() +check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil, + "the white blink follows the A press (GBPalWhiteOutWithDelay3)") +for _ = 1, 10 do frame({}) end +eq(Game.stack:top(), ow, "back on the map after the blink") + +-- restore the spied constructor so later dofile'd suites are unaffected +TextBox.new = realTextBoxNew +popAll() + +print(("parity I_M: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-I_M assertion(s) failed") end diff --git a/tests/parity_J.lua b/tests/parity_J.lua new file mode 100644 index 00000000..acb5d240 --- /dev/null +++ b/tests/parity_J.lua @@ -0,0 +1,373 @@ +-- Parity test, Workstream J. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions per your spec test plan === + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local MoveEffects = require("src.battle.MoveEffects") + +local function freshBattle() + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local tb = BattleState.newWild(Game, "PIDGEY", 10) + return tb +end + +-- scripted rng: pops the given values, then always returns the max roll +local function mkseq(vals) + local i = 0 + return function(a, b) + i = i + 1 + return vals[i] ~= nil and vals[i] or b + end +end + +-- (1) TRANSFORM: user.sprite morphs into the target species pic AND the +-- target's stat stages are copied (not cleared). transform.asm:31-53 +-- (AnimationTransformMon) + :57-132 (copies wEnemyMonStatMods). +do + local tb = freshBattle() + local preSprite = tb.player.sprite + tb.enemy.stages.attack = 2 + tb.enemy.stages.speed = -1 + MoveEffects.primary.TRANSFORM_EFFECT(tb, tb.player, tb.enemy) + check(tb.player.sprite ~= preSprite, "transform swaps the user's sprite") + eq(tb.player.sprite.path, Data.pokemon.PIDGEY.spriteBack, + "player-side transform uses the target species BACK pic") + eq(tb.player.stages.attack, 2, "transform copies target's +attack stage") + eq(tb.player.stages.speed, -1, "transform copies target's -speed stage") + -- deep copy: later changes to the target must not bleed into the user + tb.enemy.stages.attack = 5 + eq(tb.player.stages.attack, 2, "transform stages are a deep copy") + -- enemy-side transform uses the FRONT pic + local tb2 = freshBattle() + MoveEffects.primary.TRANSFORM_EFFECT(tb2, tb2.enemy, tb2.player) + eq(tb2.enemy.sprite.path, Data.pokemon.BULBASAUR.spriteFront, + "enemy-side transform uses the target species FRONT pic") +end + +-- (2) TRAPPING: continueTrapping replays the trapping move's per-turn +-- animation (core.asm:3554-3566 -> GetPlayerAnimationType). +do + local tb = freshBattle() + -- performMove WRAP rng: accuracy, crit, damage-random, trapping counter + tb.rng = mkseq({ 0, 255, 255, 0 }) + tb:performMove(tb.player, tb.enemy, { id = "WRAP", pp = 10 }) + eq(tb.player.trapMove, "WRAP", "the trapping move id is stored on the user") + -- keep the victim alive so the continuation stays clean + tb.enemy.mon.stats.hp = 999 + tb.enemy.mon.hp = 999 + tb.queue = {} + tb.nextInsert = 0 + tb:continueTrapping(tb.player, tb.enemy) + local animRow + for _, row in ipairs(tb.queue) do + if row.anim == "WRAP" then animRow = row end + end + check(animRow ~= nil, "continueTrapping queues an anim row for the trap move") + check(animRow and animRow.attackerIsPlayer == true, + "the queued trap anim row is attributed to the attacker") +end + +-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the +-- move executes, MoveHitTest runs, and only on a hit does the player's +-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link +-- battles roll a random slot (.getRandomMove). The copied move +-- overwrites the used slot's ID in place (the PP byte is untouched, +-- :1261-1266) and the id snaps back when the battler leaves play. +local function queueScan(tb) + local found = { texts = {} } + for _, row in ipairs(tb.queue) do + if row.mimicSelect then found.chooser = row.mimicSelect end + if row.anim == "MIMIC" then found.anim = true end + if row.text then table.insert(found.texts, row.text) end + end + return found +end +local function hasText(found, pat) + for _, t in ipairs(found.texts) do if t:find(pat) then return true end end + return false +end +do + -- player Mimic HIT: the chooser row is queued only after the hit test + local tb = freshBattle() + tb.kind = "wild" + tb.player.curMoves[1] = { id = "MIMIC", pp = 10 } -- aliases mon.moves + tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } } + tb.rng = function(a, b) return a end -- accuracy roll 0: hit + tb.queue, tb.nextInsert = {}, 0 + tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1]) + local found = queueScan(tb) + check(found.chooser ~= nil, "player Mimic hit queues the mid-move chooser row") + check(not found.anim, "no Mimic animation before the copy is picked") + eq(tb.player.curMoves[1].id, "MIMIC", "the slot is untouched until the pick") + -- apply the pick like the mimicSelect A-handler does + tb.nextInsert = 0 + tb:applyMimic(found.chooser.user, found.chooser.target, found.chooser.moveInst, 2) + eq(tb.player.curMoves[1].id, "SAND_ATTACK", + "the pick overwrites Mimic's slot id (slot 2 copied)") + eq(tb.player.curMoves[1].pp, 9, + "the copy inherits Mimic's remaining PP (only the move id byte is written)") + eq(tb.player.mon.moves[1].id, "SAND_ATTACK", + "the battle slot aliases the party slot (DecrementPP hits both)") + check(tb.player.curMoves[1].mimic == true, "the copied slot is flagged mimic") + found = queueScan(tb) + check(found.anim, "the Mimic animation plays after the copy") + check(hasText(found, "learned"), "the 'learned MOVE!' text follows") + -- the id snaps back when the battler leaves play; spent PP stays spent + tb:restoreMimicked(tb.player) + eq(tb.player.mon.moves[1].id, "MIMIC", "leaving play restores the party move id") + eq(tb.player.mon.moves[1].pp, 9, "spent PP stays spent after the restore") + + -- player Mimic MISS: "But, it failed!", no chooser, no animation + local tbm = freshBattle() + tbm.kind = "wild" + tbm.player.curMoves[1] = { id = "MIMIC", pp = 10 } + tbm.enemy.curMoves = { { id = "GUST", pp = 35 } } + tbm.rng = function(a, b) return b end -- accuracy roll 255: miss + tbm.queue, tbm.nextInsert = {}, 0 + tbm:performMove(tbm.player, tbm.enemy, tbm.player.curMoves[1]) + local fm = queueScan(tbm) + check(fm.chooser == nil, "a missed Mimic never opens the chooser") + check(not fm.anim, "a missed Mimic plays no animation") + check(hasText(fm, "But, it failed!"), + "a missed Mimic prints PrintButItFailedText_") + eq(tbm.player.curMoves[1].id, "MIMIC", "a missed Mimic copies nothing") + + -- a mid-Fly/Dig target also fails (.mimicMissed via INVULNERABLE) + local tbi = freshBattle() + tbi.kind = "wild" + tbi.player.curMoves[1] = { id = "MIMIC", pp = 10 } + tbi.enemy.curMoves = { { id = "GUST", pp = 35 } } + tbi.enemy.invulnerable = true + tbi.rng = function(a, b) return a end + tbi.queue, tbi.nextInsert = {}, 0 + tbi:performMove(tbi.player, tbi.enemy, tbi.player.curMoves[1]) + local fi = queueScan(tbi) + check(fi.chooser == nil and hasText(fi, "But, it failed!"), + "Mimic fails outright against a mid-Fly/Dig target") + + -- enemy Mimic: random slot of the player, no menu (.getRandomMove) + local tbe = freshBattle() + tbe.kind = "wild" + tbe.player.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } } + tbe.enemy.curMoves[1] = { id = "MIMIC", pp = 10 } + tbe.rng = mkseq({ 0, 2 }) -- accuracy hit, then random slot 2 + tbe.queue, tbe.nextInsert = {}, 0 + tbe:performMove(tbe.enemy, tbe.player, tbe.enemy.curMoves[1]) + local fe = queueScan(tbe) + check(fe.chooser == nil, "enemy Mimic never opens a chooser") + eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK", + "enemy Mimic copies a random player move immediately") + eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP") + check(fe.anim and hasText(fe, "learned"), + "enemy Mimic still plays the animation and learned text") + + -- link battle: the player's Mimic rolls random too (no chooser) + local tbl = freshBattle() + tbl.kind = "link" + tbl.player.curMoves[1] = { id = "MIMIC", pp = 10 } + tbl.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } } + tbl.rng = mkseq({ 0, 1 }) -- accuracy hit, random slot 1 + tbl.queue, tbl.nextInsert = {}, 0 + tbl:performMove(tbl.player, tbl.enemy, tbl.player.curMoves[1]) + local fl = queueScan(tbl) + check(fl.chooser == nil, "link Mimic skips the interactive chooser") + eq(tbl.player.curMoves[1].id, "GUST", "link Mimic rolls random (slot 1)") + tbl:restoreMimicked(tbl.player) + + -- the test-injection hook still short-circuits the player's menu + local tbh = freshBattle() + tbh.kind = "wild" + tbh.player.curMoves[1] = { id = "MIMIC", pp = 10 } + tbh.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } } + tbh.mimicChoice = function(self, target) return 2 end + tbh.rng = function(a, b) return a end + tbh.queue, tbh.nextInsert = {}, 0 + tbh:performMove(tbh.player, tbh.enemy, tbh.player.curMoves[1]) + eq(tbh.player.curMoves[1].id, "SAND_ATTACK", + "the mimicChoice hook applies the pick without a chooser row") +end + +-- (3b) MIMIC chooser wiring through the queue: the mimicSelect row +-- flips the phase when it reaches the queue head, and the A-press +-- applies the highlighted slot (B never cancels: MoveSelectionMenu's +-- mimic menu watches only UP/DOWN/A, core.asm:2553-2557). +do + local pressed = {} + local tb = freshBattle() + tb.game = { input = { wasPressed = function(_, k) return pressed[k] or false end }, + stack = { top = function() return tb end }, + save = Game.save } + tb.kind = "wild" + tb.player.curMoves[1] = { id = "MIMIC", pp = 10 } + tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } } + tb.rng = function(a, b) return a end + tb.queue, tb.nextInsert = {}, 0 + tb.phase = "messages" + tb.afterQueue = "menu" + tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1]) + -- drain the queue past the announcement + 50-frame beat + for _ = 1, 400 do + if tb.phase ~= "messages" then break end + pressed.a = true + tb:updateQueue() + if tb.current then tb.current = nil end -- fast-forward text rows + end + pressed.a = false + eq(tb.phase, "mimicSelect", "the queued chooser row enters the mimicSelect phase") + eq(#tb.mimicMoves, 2, "the chooser lists the enemy's moves") + eq(tb.mimicIndex, 1, "the cursor starts on the first move") + -- B does nothing (no cancel path in the mimic menu) + pressed.b = true + tb:update(1 / 60) + pressed.b = false + eq(tb.phase, "mimicSelect", "B does not leave the mimic menu") + -- DOWN then A copies slot 2 + pressed.down = true + tb:update(1 / 60) + pressed.down = false + eq(tb.mimicIndex, 2, "DOWN moves the chooser cursor") + pressed.a = true + tb:update(1 / 60) + pressed.a = false + eq(tb.phase, "messages", "A returns to the message queue") + eq(tb.player.curMoves[1].id, "SAND_ATTACK", "A applies the highlighted slot") + tb:restoreMimicked(tb.player) +end + +-- (4) BIDE (regression): Gen 1 never rolls accuracy on release, so the +-- stored energy unleashes as bideDamage*2 even when an accuracy roll +-- would have missed (effects.asm:764-789 + core.asm:3481-3529). +do + local tb = freshBattle() + tb.enemy.mon.stats.hp = 200 + tb.enemy.mon.hp = 200 + local rngCalls = 0 + tb.rng = function(a, b) rngCalls = rngCalls + 1; return b end -- 255 -> would miss + tb.player.bideTurns = 1 + tb.player.bideDamage = 50 + tb:continueBide(tb.player, tb.enemy) + eq(tb.player.bideTurns, nil, "Bide releases on schedule") + eq(tb.enemy.mon.hp, 100, "Bide release deals bideDamage*2 (2*50)") + eq(rngCalls, 0, "Bide release consumes no rng for an accuracy check") +end + +-- (5) OLD MAN catch tutorial (DisplayBattleMenu's old-man branch, +-- core.asm:2018-2050 + BagWasSelected:2193-2210 + ItemUseBall's +-- BATTLE_TYPE_OLD_MAN forks): no input is read at the battle menu -- +-- the cursor hovers FIGHT for 80 frames, hops to ITEM for 50, and the +-- item menu (one POKé BALL x50) is forced. The item list is scripted +-- too (DisplayListMenuID's old-man branch, home/list_menu.asm:65-80): +-- input is never read, the '▶' hovers POKé BALL for 80 frames, then the +-- auto A-press leaves the hollow '▷' on the row and the ball is used. +-- The old man NEVER attacks; the throw skips the catch calc entirely +-- ($43 = always caught) and the Weedle is not kept. +do + local pressed = {} + local stack = { states = {} } + function stack:push(s) table.insert(self.states, s) end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + local fg = { + data = Data, + save = require("src.core.SaveData").newGame(), + input = { wasPressed = function(_, k) return pressed[k] or false end }, + stack = stack, + } + fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local seen = {} + local demo = BattleState.newWild(fg, "WEEDLE", 5) + demo:makeOldManDemo() + local finished = false + demo.onFinish = function() finished = true end + local origStart = demo.startMessage + demo.startMessage = function(self, item) + table.insert(seen, item.text) + origStart(self, item) + end + stack:push(demo) + demo:enter() + check(demo.playerBackPic == nil or tostring(demo.playerBackPic.path or ""):find("oldman") ~= nil + or demo.demo, "the demo uses the old man back pic slot") -- headless: pic may be nil + -- intro text auto-advances on A + for _ = 1, 300 do + if demo.phase == "menu" then break end + pressed.a = true + demo:update(1 / 60) + end + pressed.a = false + eq(demo.phase, "menu", "the demo reaches the battle menu") + -- the scripted cursor ignores input and holds the menu for 130 frames + local frames = 0 + for _ = 1, 200 do + if stack:top() ~= demo then break end + pressed.a = true -- must be ignored: the old man script reads no input + if demo.phase == "menu" then frames = frames + 1 end + demo:update(1 / 60) + end + pressed.a = false + check(stack:top() ~= demo, "the ITEM menu is forced without input") + eq(frames, 131, "FIGHT hover (80) + ITEM hover (50) frames before the bag") + local bag = stack:top() + eq(bag.items and #bag.items, 1, "the old man's bag lists exactly one item") + eq(bag.items[1].label, "POKé BALL", "the item is a POKé BALL") + eq(bag.items[1].right, "x50", "with quantity x50 (OldManItemList)") + -- the list script (home/list_menu.asm:65-80): input is never read -- + -- B can't back out -- and the '▶' hovers POKé BALL for 80 frames + local enemyHP = demo.enemy.mon.hp + pressed.b = true + local hover = 0 + for _ = 1, 80 do + if stack:top() ~= bag then break end + hover = hover + 1 + bag:update(1 / 60) + end + pressed.b = false + check(stack:top() == bag, "the old man's bag reads no input (B can't back out)") + eq(hover, 80, "the '▶' hovers POKé BALL for 80 frames") + check(not bag.hollowIndex, "the cursor stays the filled '▶' through the hover") + -- frame 81: the auto A-press leaves the hollow '▷' on the chosen row + -- (PlaceUnfilledArrowMenuCursor) while ItemUseBall spins up + bag:update(1 / 60) + eq(bag.hollowIndex, 1, "the auto-A leaves the hollow '▷' on the POKé BALL row") + check(stack:top() == bag, "the hollow '▷' is visible while the list is open") + -- the throw follows without any input: OLD MAN throws, it ALWAYS + -- catches, nothing is kept + for _ = 1, 20 do + if stack:top() ~= bag then break end + bag:update(1 / 60) + end + check(stack:top() ~= bag, "the ball is thrown without input") + for _ = 1, 2000 do + if finished then break end + pressed.a = true + demo:update(1 / 60) + end + pressed.a = false + check(finished, "the throw ends the demo battle") + local function sawText(pat) + for _, t in ipairs(seen) do if t and t:find(pat, 1, true) then return true end end + return false + end + check(sawText("OLD MAN used\nPOKé BALL!"), "the throw is credited to OLD MAN") + check(sawText("All right!\nWEEDLE was\ncaught!"), "the ball always catches (_ItemUseBallText05)") + eq(demo.enemy.mon.hp, enemyHP, "the old man never attacks (Weedle at full HP)") + eq(#fg.save.party, 1, "the caught Weedle is NOT added to the party") + check(not (fg.save.pokedex and fg.save.pokedex.owned and fg.save.pokedex.owned.WEEDLE), + "the caught Weedle is NOT added to the dex") +end + +print(("parity J: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-J assertion(s) failed") end diff --git a/tests/parity_K.lua b/tests/parity_K.lua new file mode 100644 index 00000000..ee6bd2ab --- /dev/null +++ b/tests/parity_K.lua @@ -0,0 +1,140 @@ +-- Parity test, Workstream K. +-- Self-contained: run via `luajit tests/parity_K.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +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.PALLET_TOWN) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local TrainerAI = require("src.battle.TrainerAI") +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local rngLo = function(a, b) return a end -- picks first minimum +local rngHi = function(a, b) return b end -- picks last minimum + +-- === CASE mod1: base-10 additive scoring, MINIMUM selection === +-- TOXIC (power 0, POISON_EFFECT) vs a statused player scores 10+5=15; +-- TACKLE scores 10. The min-score move (TACKLE) is chosen deterministically +-- regardless of the RNG, and the discouraged status move is NEVER selectable. +do + local aiMon = { curMoves = { { id = "TOXIC", pp = 10 }, { id = "TACKLE", pp = 10 } } } + local aiBattle = { enemyAIMods = { 1 }, data = Data, + player = { mon = { status = "PAR" }, curTypes = { "NORMAL" } } } + eq(TrainerAI.chooseMove(aiMon, rngLo, aiBattle).id, "TACKLE", + "mod1: discouraged status move never wins (rng low)") + local aiMon2 = { curMoves = { { id = "TOXIC", pp = 10 }, { id = "TACKLE", pp = 10 } } } + eq(TrainerAI.chooseMove(aiMon2, rngHi, aiBattle).id, "TACKLE", + "mod1: discouraged status move never wins (rng high)") +end + +-- A single-move mon still returns that move (minimum of one). +do + local solo = { curMoves = { { id = "TOXIC", pp = 10 } } } + local aiBattle = { enemyAIMods = { 1 }, data = Data, + player = { mon = { status = "PAR" }, curTypes = { "NORMAL" } } } + eq(TrainerAI.chooseMove(solo, rngLo, aiBattle).id, "TOXIC", + "mod1: single move is the minimum of one") +end + +-- === CASE mod2: 2nd-selection gating + -1 encouragement === +-- GROWL (ATTACK_DOWN1_EFFECT) is encouraged only when aiLayer2 == 1. +do + local aiMon = { curMoves = { { id = "GROWL", pp = 10 }, { id = "TACKLE", pp = 10 } } } + local aiBattle = { enemyAIMods = { 2 }, data = Data, + player = { mon = {}, curTypes = { "NORMAL" } } } + -- 1st selection (aiLayer2 0->1, no encouragement): tie 10/10, rng low -> GROWL + eq(TrainerAI.chooseMove(aiMon, rngLo, aiBattle).id, "GROWL", + "mod2: no encouragement on the first selection (tie, first min)") + -- 2nd selection (aiLayer2 1->2, encouraged): GROWL 9 < TACKLE 10 -> GROWL any rng + eq(TrainerAI.chooseMove(aiMon, rngHi, aiBattle).id, "GROWL", + "mod2: stat move encouraged on the second selection") + -- 3rd selection (aiLayer2 2->3, no encouragement): tie again, rng high -> TACKLE + eq(TrainerAI.chooseMove(aiMon, rngHi, aiBattle).id, "TACKLE", + "mod2: encouragement expires after the second selection") +end + +-- === CASE mod3: first-row super-effective lookup on a non-damaging move === +-- THUNDER_WAVE (ELECTRIC) vs WATER reads row 20 -> 9; TACKLE (NORMAL) has no +-- NORMAL->WATER row -> 10. THUNDER_WAVE is the minimum for any rng. +do + local aiMon = { curMoves = { { id = "THUNDER_WAVE", pp = 10 }, { id = "TACKLE", pp = 10 } } } + local aiBattle = { enemyAIMods = { 3 }, data = Data, + player = { mon = {}, curTypes = { "WATER" } } } + eq(TrainerAI.chooseMove(aiMon, rngLo, aiBattle).id, "THUNDER_WAVE", + "mod3: super-effective non-damaging move is the minimum (rng low)") + local aiMon2 = { curMoves = { { id = "THUNDER_WAVE", pp = 10 }, { id = "TACKLE", pp = 10 } } } + eq(TrainerAI.chooseMove(aiMon2, rngHi, aiBattle).id, "THUNDER_WAVE", + "mod3: super-effective non-damaging move is the minimum (rng high)") +end + +-- === CASE class-shaped coverage (real per-class aiMods) === +-- MISTY {1,3}, RIVAL2/RIVAL3 {1,3}, LORELEI {1,2,3}: against an unstatused +-- Water player, THUNDER_WAVE gets mod3 -1 while mod1/mod2 are no-ops. +for _, class in ipairs({ { "MISTY", { 1, 3 } }, { "RIVAL2/3", { 1, 3 } }, { "LORELEI", { 1, 2, 3 } } }) do + local aiMon = { curMoves = { { id = "THUNDER_WAVE", pp = 10 }, { id = "TACKLE", pp = 10 } } } + local aiBattle = { enemyAIMods = class[2], data = Data, + player = { mon = {}, curTypes = { "WATER" } } } + eq(TrainerAI.chooseMove(aiMon, rngHi, aiBattle).id, "THUNDER_WAVE", + "class " .. class[1] .. ": mod3 super-effective pick, mod1/2 no-op") +end + +-- BRUNO/AGATHA {1}: an UNSTATUSED player makes mod1 a no-op, so scores tie +-- and the first minimum is chosen. +do + local aiMon = { curMoves = { { id = "TOXIC", pp = 10 }, { id = "TACKLE", pp = 10 } } } + local aiBattle = { enemyAIMods = { 1 }, data = Data, + player = { mon = {}, curTypes = { "NORMAL" } } } + eq(TrainerAI.chooseMove(aiMon, rngLo, aiBattle).id, "TOXIC", + "class BRUNO/AGATHA: mod1 no-op vs unstatused player (tie, first min)") +end + +-- === Min filter: a clearly-worse move is NEVER returned, across RNG values === +-- TOXIC scores 15 (mod1 +5 vs statused player) while TACKLE and GROWL tie at +-- 10; the minima are {TACKLE, GROWL} and TOXIC can never be selected. +do + local aiBattle = { enemyAIMods = { 1 }, data = Data, + player = { mon = { status = "SLP" }, curTypes = { "NORMAL" } } } + for _, rng in ipairs({ rngLo, rngHi, function(a, b) return a end }) do + local aiMon = { curMoves = { { id = "TOXIC", pp = 10 }, { id = "TACKLE", pp = 10 }, + { id = "GROWL", pp = 10 } } } + check(TrainerAI.chooseMove(aiMon, rng, aiBattle).id ~= "TOXIC", + "min filter: the +5-discouraged move is never selectable") + end +end + +-- === Wild / no-mod uniform pick guard is preserved === +do + local aiMon = { curMoves = { { id = "TACKLE", pp = 10 }, { id = "GROWL", pp = 10 } } } + eq(TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = {}, data = Data }).id, "TACKLE", + "no-mod guard: uniform pick (rng low -> first)") + local aiMon2 = { curMoves = { { id = "TACKLE", pp = 10 }, { id = "GROWL", pp = 10 } } } + eq(TrainerAI.chooseMove(aiMon2, rngHi, { enemyAIMods = {}, data = Data }).id, "GROWL", + "no-mod guard: uniform pick (rng high -> last)") +end + +-- === STRUGGLE fallback when nothing is usable === +do + local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } } + local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data, + player = { mon = {}, curTypes = {} } }) + check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP") +end + +-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) === +-- Switch when >= 1 non-active unfainted backup exists (active + 1 backup = 2 +-- total unfainted, the oracle's threshold). +do + local withBackup = { enemyParty = { { hp = 50 }, { hp = 50 } }, enemyIndex = 1 } + local act = TrainerAI.switchAction(withBackup) + check(act and act.special == "aiSwitch" and act.index == 2, + "switchAction: switches with one unfainted backup") + local noBackup = { enemyParty = { { hp = 50 }, { hp = 0 } }, enemyIndex = 1 } + eq(TrainerAI.switchAction(noBackup), nil, + "switchAction: no switch when no backup is unfainted") +end + +print(("parity K: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-K assertion(s) failed") end diff --git a/tests/parity_L.lua b/tests/parity_L.lua new file mode 100644 index 00000000..d69e4dc2 --- /dev/null +++ b/tests/parity_L.lua @@ -0,0 +1,171 @@ +-- Parity test, Workstream L. +-- Self-contained: run via `luajit tests/parity_L.lua`; also dofile'd by +-- tests/run_tests.lua's aggregator. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local MoveEffects = require("src.battle.MoveEffects") +local Damage = require("src.battle.Damage") +local TurnOrder = require("src.battle.TurnOrder") +local TypeChart = require("src.battle.TypeChart") +-- TypeChart is normally loaded by BattleState.newBattle; Damage.compute +-- needs its matchup index, so wire it up directly for this unit test. +TypeChart.load(Data) + +-- Minimal battler factory covering every volatile Haze touches. +local function battler(o) + o = o or {} + return { + stages = o.stages or {}, + confusedTurns = o.confusedTurns, + leechSeeded = o.leechSeeded, + toxicCounter = o.toxicCounter, + reflect = o.reflect, + lightScreen = o.lightScreen, + mist = o.mist, + focusEnergy = o.focusEnergy, + disabledSlot = o.disabledSlot, + disabledTurns = o.disabledTurns, + xAccuracy = o.xAccuracy, + mon = o.mon or {}, + curStats = o.curStats, + curTypes = o.curTypes, + badges = o.badges, + name = o.name or "MON", + } +end + +-- ===================================================================== +-- (A) Already-faithful Haze cells (haze.asm HazeEffect_ / CureVolatileStatuses) +-- ===================================================================== + +-- The USER carries every clearable volatile plus a *badly*-poisoned status. +local u = battler{ + stages = { attack = 3, defense = -2, speed = 1, special = 4, accuracy = 2, evasion = -1 }, + confusedTurns = 5, leechSeeded = true, toxicCounter = 3, + reflect = true, lightScreen = true, mist = true, focusEnergy = true, + disabledSlot = 2, disabledTurns = 4, xAccuracy = true, + mon = { status = "PSN" }, name = "USER", +} +-- The TARGET is asleep and also carries volatiles, to prove both sides clear. +local t = battler{ + stages = { defense = 2, speed = -3 }, + confusedTurns = 4, leechSeeded = true, toxicCounter = 6, + reflect = true, lightScreen = true, mist = true, focusEnergy = true, + disabledSlot = 1, disabledTurns = 2, xAccuracy = true, + mon = { status = "SLP" }, name = "TARGET", +} +local msg = MoveEffects.primary.HAZE_EFFECT(nil, u, t) + +check(next(u.stages) == nil, "Haze clears all of the user's stat stages") +check(next(t.stages) == nil, "Haze clears all of the target's stat stages") + +local function volatilesCleared(b, who) + check(b.confusedTurns == nil, who .. ": confusion cleared") + check(b.leechSeeded == nil, who .. ": leech seed cleared") + check(b.toxicCounter == nil, who .. ": badly-poisoned bit cleared (toxic -> regular)") + check(b.reflect == nil, who .. ": reflect cleared") + check(b.lightScreen == nil, who .. ": light screen cleared") + check(b.mist == nil, who .. ": mist cleared") + check(b.focusEnergy == nil, who .. ": focus energy cleared") + check(b.disabledSlot == nil, who .. ": disabled slot cleared") + check(b.disabledTurns == nil, who .. ": disabled turns cleared") + check(b.xAccuracy == nil, who .. ": X ACCURACY cleared") +end +volatilesCleared(u, "user") +volatilesCleared(t, "target") + +-- User's own non-volatile status is intentionally KEPT (haze.asm cures the +-- target only); the badly-poisoned USER reverts to regular poison. +eq(u.mon.status, "PSN", "user's own major status is kept (still poisoned)") +-- Target's major status is cured; its SLP forfeits the move this turn. +eq(t.mon.status, nil, "target's major status is cured") +check(t.skipMove == true, "curing target's sleep forfeits its move (skipMove)") +eq(msg[1], "All STATUS changes\nare eliminated!", "Haze prints the elimination text") + +-- FRZ target also forfeits its move. +local frz = battler{ mon = { status = "FRZ" }, name = "FROZEN" } +MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, frz) +eq(frz.mon.status, nil, "target's freeze is cured") +check(frz.skipMove == true, "curing target's freeze forfeits its move") + +-- Badly-poisoned TARGET: status cured, no forfeit, toxic counter gone. +local psnT = battler{ mon = { status = "PSN" }, toxicCounter = 4, name = "PSN_T" } +MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, psnT) +eq(psnT.mon.status, nil, "badly-poisoned target is fully cured of poison") +check(psnT.toxicCounter == nil, "badly-poisoned target's toxic counter cleared") +check(not psnT.skipMove, "curing poison does NOT forfeit the target's move") + +-- BRN / PAR targets: cured, no forfeit. +for _, st in ipairs({ "BRN", "PAR" }) do + local tb = battler{ mon = { status = st }, name = st } + MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, tb) + eq(tb.mon.status, nil, "target's " .. st .. " is cured") + check(not tb.skipMove, st .. " target keeps its move (no sleep/freeze forfeit)") +end + +-- A burned USER keeps its own burn (status not the one Haze cures). +local burnedUser = battler{ mon = { status = "BRN" }, name = "BURNER" } +MoveEffects.primary.HAZE_EFFECT(nil, burnedUser, battler{ mon = {} }) +eq(burnedUser.mon.status, "BRN", "user's own burn is not cured by Haze") + +-- ===================================================================== +-- (B) New quirk: Haze temporarily lifts the burn Attack-halving +-- (haze.asm ResetStats copies unmodified stats over battle stats). +-- ===================================================================== + +local ruleset = { randMin = 255, randMax = 255 } -- identity random factor +local move = { id = "TACKLE", type = "NORMAL", power = 80 } -- physical +local rng = function(_, b) return b end +local defender = { + curStats = { attack = 50, defense = 50, special = 50, speed = 50, hp = 100 }, + stages = {}, curTypes = { "NORMAL" }, + mon = { level = 50, stats = { hp = 100 } }, name = "DEF", +} +local function attacker(status, hz) + return { + curStats = { attack = 100, defense = 50, special = 50, speed = 50, hp = 100 }, + stages = {}, curTypes = { "WATER" }, -- WATER so a NORMAL move gets no STAB + mon = { status = status, level = 50, stats = { hp = 100 } }, + hazeStatReset = hz, name = "ATK", + } +end +local opts = { forceCrit = false, rng = rng } +local dHealthy = Damage.compute(ruleset, attacker(nil, nil), defender, move, opts) +local dBurnedRaw = Damage.compute(ruleset, attacker("BRN", nil), defender, move, opts) +local hazedAtk = attacker("BRN", true) +local dBurnedHaze = Damage.compute(ruleset, hazedAtk, defender, move, opts) + +check(dBurnedRaw < dHealthy, "burn halves a burned mon's physical damage (sanity)") +eq(dBurnedHaze, dHealthy, "Haze lifts the burn Attack-halving (damage == unburned)") + +-- A stat-stage change re-bakes the penalty (effects.asm:505-506). Bump the +-- attacker's DEFENSE (irrelevant to its own offense) so only hazeStatReset flips. +MoveEffects.primary.DEFENSE_UP1_EFFECT(nil, hazedAtk, nil) +check(hazedAtk.hazeStatReset == nil, "a stat-stage change re-arms the burn penalty") +local dAfter = Damage.compute(ruleset, hazedAtk, defender, move, opts) +eq(dAfter, dBurnedRaw, "burn Attack-halving returns after the stage change") + +-- ===================================================================== +-- (C) New quirk: Haze temporarily lifts the paralysis Speed-quartering. +-- ===================================================================== + +local para = battler{ + curStats = { speed = 100, attack = 50, defense = 50, special = 50, hp = 100 }, + mon = { status = "PAR", level = 50, stats = { hp = 100 } }, name = "PARA", +} +eq(TurnOrder.effectiveSpeed(para), 25, "paralysis quarters speed before Haze (100 -> 25)") +MoveEffects.primary.HAZE_EFFECT(nil, para, battler{ mon = {} }) +eq(TurnOrder.effectiveSpeed(para), 100, "Haze lifts paralysis Speed-quartering") +-- Re-arm via an ATTACK stage change (irrelevant to the speed calc). +MoveEffects.primary.ATTACK_UP1_EFFECT(nil, para, nil) +check(para.hazeStatReset == nil, "stage change re-arms the paralysis penalty") +eq(TurnOrder.effectiveSpeed(para), 25, "Speed-quartering resumes after the stage change") + +print(("parity L: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-L assertion(s) failed") end diff --git a/tests/parity_flavor.lua b/tests/parity_flavor.lua new file mode 100644 index 00000000..43598d6f --- /dev/null +++ b/tests/parity_flavor.lua @@ -0,0 +1,53 @@ +-- Parity test, Workstream A flavor backlog (data/scripts/flavor/*.lua + +-- gym guides in story7). Asserts every ported text_asm talk script is +-- registered and reachable via the map-script registry, and that every +-- static text label it shows actually exists in the generated text, so +-- the "uses text_asm; showing plain text" fallback no longer fires for +-- these NPCs. Self-contained; run via `luajit tests/parity_flavor.lua`. +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.PALLET_TOWN) then Data:load() end +local init = require("data.scripts.init") +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end + +-- (1) every ported (map, TEXT const) resolves via the registry +local ported = 0 +for _, modname in ipairs({ "data.scripts.flavor_all", "data.scripts.story7" }) do + for mapId, m in pairs(require(modname)) do + if m.talk then + for const in pairs(m.talk) do + ported = ported + 1 + check(init.talkScript(mapId, const) ~= nil, + "registry resolves " .. mapId .. "/" .. const) + end + end + end +end +check(ported >= 100, "ported at least 100 flavor/guide talk scripts (got " .. ported .. ")") + +-- (2) every static show_text/ask label in a row-list script exists in the +-- generated text (function-handler scripts resolve labels at runtime) +local labels, missing = 0, 0 +for _, modname in ipairs({ "data.scripts.flavor_all", "data.scripts.story7" }) do + for _, m in pairs(require(modname)) do + if m.talk then + for _, s in pairs(m.talk) do + if type(s) == "table" then + for _, row in ipairs(s) do + if type(row) == "table" and (row[1] == "show_text" or row[1] == "ask") + and type(row[2]) == "string" and row[2]:sub(1, 1) == "_" then + labels = labels + 1 + if Data.text[row[2]] == nil then missing = missing + 1; print("FAIL missing text " .. row[2]) end + end + end + end + end + end + end +end +check(missing == 0, ("all %d row-list text labels exist in generated text"):format(labels)) + +print(("parity flavor: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-flavor assertion(s) failed") end diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua new file mode 100644 index 00000000..c51e3a87 --- /dev/null +++ b/tests/parity_gbcfx.lua @@ -0,0 +1,86 @@ +-- Parity test, GBC FX ladder (Pixel Transparency shader). +-- Unit-tests the Lua-side API of src/render/GBCFX.lua headless under the +-- love stub: level clamping, the OFF→1→2→3→4→OFF cycle, options plumbing, +-- level labels, and that active()/present() degrade gracefully when the +-- stub offers no love.graphics.newShader (shader() returns nil). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions === + +local GBCFX = require("src.render.GBCFX") + +-- defaults +eq(GBCFX.level, 0, "gbcfx starts at level 0 (OFF)") +eq(#GBCFX.LABELS, 5, "five labels (OFF + 4 levels)") +eq(GBCFX.LABELS[1], "OFF", "first label is OFF") + +-- setLevel clamps and floors +GBCFX.setLevel(2) +eq(GBCFX.level, 2, "setLevel stores an in-range level") +GBCFX.setLevel(-3) +eq(GBCFX.level, 0, "setLevel clamps below to 0") +GBCFX.setLevel(99) +eq(GBCFX.level, 4, "setLevel clamps above to 4") +GBCFX.setLevel(2.9) +eq(GBCFX.level, 2, "setLevel floors fractional levels") +GBCFX.setLevel("3") +eq(GBCFX.level, 3, "setLevel accepts numeric strings") +GBCFX.setLevel(nil) +eq(GBCFX.level, 0, "setLevel(nil) resets to OFF") +GBCFX.setLevel("junk") +eq(GBCFX.level, 0, "setLevel(non-numeric) resets to OFF") + +-- cycle wraps OFF→1→2→3→4→OFF and returns the new level +GBCFX.setLevel(0) +eq(GBCFX.cycle(), 1, "cycle OFF -> 1") +eq(GBCFX.cycle(), 2, "cycle 1 -> 2") +eq(GBCFX.cycle(), 3, "cycle 2 -> 3") +eq(GBCFX.cycle(), 4, "cycle 3 -> 4") +eq(GBCFX.cycle(), 0, "cycle 4 wraps to OFF") +eq(GBCFX.level, 0, "cycle leaves the wrapped level stored") + +-- applyOptions reads opts.gbcfx +GBCFX.applyOptions({ gbcfx = 3 }) +eq(GBCFX.level, 3, "applyOptions reads opts.gbcfx") +GBCFX.applyOptions({}) +eq(GBCFX.level, 0, "applyOptions without gbcfx resets to OFF") +GBCFX.setLevel(2) +GBCFX.applyOptions(nil) +eq(GBCFX.level, 0, "applyOptions(nil) resets to OFF") + +-- labels +eq(GBCFX.levelLabel(0), "OFF", "label for level 0") +eq(GBCFX.levelLabel(1), "1", "label for level 1") +eq(GBCFX.levelLabel(4), "4", "label for level 4") +GBCFX.setLevel(3) +eq(GBCFX.levelLabel(), "3", "levelLabel() defaults to the current level") +eq(GBCFX.levelLabel(42), "OFF", "out-of-range label falls back to OFF") + +-- headless: the love stub has no newShader, so the shader never compiles +eq(GBCFX.shader(), nil, "shader() is nil headless") +GBCFX.setLevel(4) +check(not GBCFX.active(), "active() is false headless even at level 4") + +-- present() falls back to a plain draw when the shader is unavailable +local drawn = nil +local g = love.graphics +local oldDraw, oldSetColor = g.draw, g.setColor +g.draw = function(c, x, y) drawn = { c, x, y } end +g.setColor = g.setColor or function() end +local canvas = {} +local ok, err = pcall(GBCFX.present, canvas, 5) +g.draw = oldDraw +g.setColor = oldSetColor +check(ok, "present() does not error headless (" .. tostring(err) .. ")") +check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0, + "present() falls back to a plain draw at (0,0)") + +GBCFX.setLevel(0) + +-- === summary === +print(("%d/%d checks passed"):format(total - fails, total)) +if fails > 0 then error(("parity_gbcfx: %d checks failed"):format(fails)) end diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua new file mode 100644 index 00000000..094224a4 --- /dev/null +++ b/tests/parity_hof.lua @@ -0,0 +1,147 @@ +-- Parity test, Hall of Fame credits, autosave and post-credits reset +-- (engine/movie/credits.asm HallOfFamePC/Credits, scripts/HallOfFame.asm +-- HallOfFameResetEventsAndSaveScript). +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.PALLET_TOWN) then Data:load() end +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === (1) extracted credits data matches CreditsOrder/CreditsMons === + +local credits = Data.field and Data.field.credits +check(credits ~= nil, "field.credits extracted") +credits = credits or { screens = {}, mons = {} } +eq(#credits.screens, 35, "CreditsOrder: 35 screens") +eq(#credits.mons, 15, "CreditsMons: 15 entries") +local s1 = credits.screens[1] or {} +check(s1.fade == true and s1.mon == "VENUSAUR", + "screen 1 is CRED_TEXT_FADE_MON with VENUSAUR") +local last = credits.screens[#credits.screens] or {} +check(last.copyright == true and last.fade == true and last.mon == "PARASECT", + "last screen is CRED_COPYRIGHT + CRED_TEXT_FADE_MON with PARASECT") +-- after every mon wipe BGP is left at %11000000 (text invisible), so the +-- following screen must be a FADE variant -- true of the extracted order +local fadeAfterWipe = true +local prevMon = true -- HallOfFamePC enters with BGP %11000000 +for _, s in ipairs(credits.screens) do + if prevMon and not s.fade then fadeAfterWipe = false end + prevMon = s.mon ~= nil +end +check(fadeAfterWipe, "every screen after a mon wipe fades in") + +-- === (2) Credits state machine: pokered's exact frame timing === + +local Credits = require("src.ui.Credits") +local pressed = {} +local fakeInput = { wasPressed = function(_, b) return pressed[b] or false end, + isDown = function() return false end } +local function newStack() + local stack = { states = {} } + function stack:push(s, ...) table.insert(self.states, s) if s.enter then s:enter(...) end end + function stack:pop() local s = table.remove(self.states) if s and s.exit then s:exit() end return s end + function stack:top() return self.states[#self.states] end + return stack +end + +local stack = newStack() +local game = { data = Data, input = fakeInput, stack = stack, save = {} } +local theEndAt, doneRan = nil, false +local frame = 0 +local roll = Credits.new(game, function() doneRan = true end, + function() theEndAt = frame end) +stack:push(roll) + +-- HallOfFamePC lead-in (100 blank + 128 after music starts) + per-screen +-- fade/hold/wipe + THE END (16 blank + 20 fade) + the script's 5x120 +-- DelayFrames before WaitForTextScrollButtonPress +local expected = 100 + 128 + 16 + 20 + 600 +for _, s in ipairs(credits.screens) do + expected = expected + (s.fade and 20 or 0) + + (s.mon and (s.fade and 90 or 110) or (s.fade and 120 or 140)) + + (s.mon and 27 or 0) +end +while roll.phase ~= "end_wait" and frame < expected + 120 do + frame = frame + 1 + roll:update(1 / 60) + roll:draw() -- exercise every draw path headless +end +eq(frame, expected, "credits reach the A/B wait after the exact frame count") +eq(theEndAt, expected - 600, + "onTheEnd (the SaveGameData point) fires when THE END finishes fading") +check(not doneRan, "onDone waits for the button press") +roll:update(1 / 60) -- unpressed frame: still waiting +check(roll.phase == "end_wait" and #stack.states == 1, "credits hold on THE END") +pressed.b = true -- WaitForTextScrollButtonPress takes A or B +roll:update(1 / 60) +pressed.b = nil +check(doneRan, "B on THE END pops the credits and calls onDone") +eq(#stack.states, 0, "credits popped itself") + +-- === (3) record_hall_of_fame: induction -> credits -> autosave -> Init === + +local Commands = require("src.script.Commands") +local SaveData = require("src.core.SaveData") +local stack2 = newStack() +local game2 = { data = Data, input = fakeInput, stack = stack2, + save = SaveData.newGame() } +game2.save.party = { { species = "PIKACHU", level = 81 } } +game2.save.player.map = "HALL_OF_FAME" +local wrote = false +function game2:writeSave() wrote = true; SaveData.save(self.save) end + +local co +local runner = {} +function runner:yield() coroutine.yield() end +function runner:resume() + local ok, err = coroutine.resume(co) + if not ok then error(err) end +end +local ctx = { game = game2, save = game2.save, runner = runner } +co = coroutine.create(function() Commands.record_hall_of_fame(ctx) end) +local ok, err = coroutine.resume(co) +check(ok, "record_hall_of_fame starts: " .. tostring(err)) + +eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)") +local HallOfFame = require("src.ui.HallOfFame") +check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") + +-- drive induction + full credits with A held (pages are unskippable; A +-- only advances the induction and the final THE END wait) +pressed.a = true +local guard = 0 +while coroutine.status(co) ~= "dead" and stack2:top() and guard < 30000 do + guard = guard + 1 + local top = stack2:top() + if top.update then top:update(1 / 60) end +end +pressed.a = nil +eq(coroutine.status(co), "dead", "HoF script command runs to completion") +check(guard > expected, "credits pages were not skippable by holding A") + +-- the autosave (SaveGameData while THE END is up) +check(wrote, "autosave ran during THE END") +local savedRaw = love.filesystem.read("save.lua") +local saved = savedRaw and SaveData.decode(savedRaw) or nil +check(saved ~= nil, "save.lua written and decodable") +eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN", + "wLastBlackoutMap := PALLET_TOWN before the save") +eq(saved and saved.player and saved.player.map, "HALL_OF_FAME", + "save keeps the player in the HALL_OF_FAME room") +eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted") + +-- `jp Init`: everything popped, boot sequence pushed (intro -> title) +eq(#stack2.states, 1, "soft reset leaves exactly the boot state") +local IntroMovie = require("src.ui.IntroMovie") +check(getmetatable(stack2:top()) == IntroMovie, + "post-credits state is the IntroMovie (jp Init boot path)") +local Game = require("src.core.Game") +check(type(Game.makeTitleState) == "function", + "Game:makeTitleState exists for the intro's title handoff") + +print(("parity HOF: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-HOF assertion(s) failed") end diff --git a/tests/parity_intro.lua b/tests/parity_intro.lua new file mode 100644 index 00000000..dd251000 --- /dev/null +++ b/tests/parity_intro.lua @@ -0,0 +1,191 @@ +-- Parity test, the Pallet Town intro cutscene (Oak escort) and the +-- Oak-speech shrink assets. +-- Self-contained: run via `luajit tests/parity_intro.lua`; also dofile'd +-- by tests/run_tests.lua's aggregator. +-- +-- Sources: scripts/PalletTown.asm, engine/overworld/auto_movement.asm +-- (PalletMovementScriptPointerTable, RLEList_ProfOakWalkToLab, +-- RLEList_PlayerWalkToLab), engine/overworld/pathfinding.asm +-- (FindPathToPlayer), engine/movie/oak_speech/oak_speech.asm. +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local mapScripts = require("data.scripts.init") +local pallet = mapScripts.get("PALLET_TOWN") +check(pallet and pallet.escort, "PALLET_TOWN exposes the escort tables") +local escort = pallet.escort + +local function joined(t) return table.concat(t, ",") end + +-- ===================================================================== +-- (A) Oak's walk to the player: FindPathToPlayer from his object spot +-- (8,5) to one tile below the player (hNPCPlayerYDistance decremented +-- before the predef). Reducing the greater remaining axis each step +-- (ties go to X) yields a strict zigzag. +-- ===================================================================== +eq(joined(escort.oakApproach(10)), "up,right,up,right,up", + "Oak approach to the left tile (player at 10,1)") +eq(joined(escort.oakApproach(11)), "right,up,right,up,right,up", + "Oak approach to the right tile (player at 11,1)") + +-- walking the approach from (8,5) must land exactly on (playerX, 2) +for _, px in ipairs({ 10, 11 }) do + local cx, cy = 8, 5 + local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + for _, dir in ipairs(escort.oakApproach(px)) do + cx, cy = cx + D[dir][1], cy + D[dir][2] + end + check(cx == px and cy == 2, + ("Oak's approach ends below the player at (%d,2)"):format(px)) +end + +-- ===================================================================== +-- (B) The escort to the lab. Oak: RLEList_ProfOakWalkToLab = DOWN x5, +-- LEFT, DOWN x5, RIGHT x3, UP (the NPC_CHANGE_FACING tail is a march- +-- in-place beat, not a step). Player: RLEList_PlayerWalkToLab plays in +-- reverse buffer order = DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2, and +-- the last UP is consumed by the door-warp frame -- so the realized +-- player path is Oak's path with one extra leading DOWN. +-- ===================================================================== +eq(joined(escort.oakSteps), + "down,down,down,down,down,left,down,down,down,down,down,right,right,right,up", + "Oak's walk-to-lab movement (RLEList_ProfOakWalkToLab)") +eq(#escort.oakSteps, 15, "Oak takes 15 steps") +eq(joined(escort.playerSteps), + "down," .. + "down,down,down,down,down,left,down,down,down,down,down,right,right,right,up", + "player's realized walk (RLEList_PlayerWalkToLab reversed, warp eats the 17th press)") +eq(#escort.playerSteps, 16, "player takes 16 real steps") + +-- both start one apart and stay in lockstep; both paths end on the lab +-- door at (12,11) +do + local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local ox, oy = 10, 2 -- Oak, below the player on the left tile + local px, py = 10, 1 -- player + for i = 1, #escort.playerSteps do + local od = escort.oakSteps[i] + if od then ox, oy = ox + D[od][1], oy + D[od][2] end + local pd = escort.playerSteps[i] + px, py = px + D[pd][1], py + D[pd][2] + if i < #escort.playerSteps then + check(math.abs(ox - px) + math.abs(oy - py) == 1, + ("beat %d: player stays exactly one tile behind Oak"):format(i)) + end + end + check(ox == 12 and oy == 11, "Oak's walk ends on the lab door (12,11)") + check(px == 12 and py == 11, "player's walk ends on the lab door (12,11)") +end + +-- the door tile is the real Pallet lab door and resolves to the lab's +-- second warp, the (5,11) mat the walk-in starts from +local MapLoader = require("src.world.MapLoader") +local Warp = require("src.world.Warp") +local town = MapLoader.load(Data, "PALLET_TOWN") +local w = town:warpAtCell(12, 11) +check(w and w.def.destMap == "OAKS_LAB", "(12,11) is the Oak's Lab door warp") +local dm, dx, dy = Warp.destination(Data, { destMap = "OAKS_LAB", destWarp = 2 }) +check(dm == "OAKS_LAB" and dx == 5 and dy == 11, + "the door warps onto the lab mat at (5,11)") + +-- the walk-in cast: door Oak at (5,10), desk Oak at (5,2), both +-- initially hidden; Pallet's Oak object at (8,5), initially hidden +local function obj(mapId, name) + for _, o in ipairs(Data.maps[mapId].objects) do + if o.name == name then return o end + end +end +local oak2 = obj("OAKS_LAB", "OAKSLAB_OAK2") +check(oak2 and oak2.x == 5 and oak2.y == 10 and oak2.hidden and oak2.index == 8, + "OAKSLAB_OAK2 hides at the door (5,10)") +local oak1 = obj("OAKS_LAB", "OAKSLAB_OAK1") +check(oak1 and oak1.x == 5 and oak1.y == 2 and oak1.hidden and oak1.index == 5, + "OAKSLAB_OAK1 hides behind the desk (5,2)") +local poak = obj("PALLET_TOWN", "PALLETTOWN_OAK") +check(poak and poak.x == 8 and poak.y == 5 and poak.hidden and poak.index == 1, + "PALLETTOWN_OAK hides at (8,5)") + +-- ===================================================================== +-- (C) Oak speech shrink: the extracted ShrinkPic1/ShrinkPic2 manifest +-- and the sounds/music the sequence uses. +-- ===================================================================== +local oakGfx = Data.field.oakSpeech +check(oakGfx and oakGfx.shrink1 and oakGfx.shrink2, + "field.oakSpeech lists both shrink frames") +if oakGfx then + for _, key in ipairs({ "shrink1", "shrink2" }) do + local fh = io.open(oakGfx[key], "rb") + check(fh ~= nil, ("%s exists on disk (%s)"):format(key, tostring(oakGfx[key]))) + if fh then fh:close() end + end +end +check(Data.audio.sfx and Data.audio.sfx.Shrink ~= nil, "SFX_SHRINK is extracted") +check(Data.audio.songs and Data.audio.songs.Music_Routes2 ~= nil + and Data.audio.songs.Music_MeetProfOak ~= nil, + "Routes2 + MeetProfOak songs are extracted") + +-- ===================================================================== +-- (D) Lab walk-in with UP held: a Delay3 emote queued from Oak's entry +-- onDone must not leave a one-frame handleInput gap, or the held press +-- walks an extra tile and PlayerEntryMovementRLE (up x8) lands on desk +-- Oak at (5,2) instead of (5,3). +-- ===================================================================== +do + local SaveData = require("src.core.SaveData") + local Game = require("src.core.Game") + local StateStack = require("src.core.StateStack") + local OverworldState = require("src.world.OverworldController") + local Commands = require("src.script.Commands") + local prev = { data = Game.data, save = Game.save, stack = Game.stack, + input = Game.input, renderer = Game.renderer, + overworld = Game.overworld } + Game.data = Data + Game.save = SaveData.newGame(Data) + Game.save.player.name = "RED" + Game.save.objectToggles = { OAKS_LAB = { OAKSLAB_OAK2 = true } } + StateStack:init() + Game.stack = StateStack + Game.input = { + isDown = function(_, b) return b == "up" end, + wasPressed = function() return false end, + step = function() end, state = {}, pressQueue = {}, + } + Game.renderer = { + beginWorldPass = function() end, endWorldPass = function() end, + beginUIPass = function() end, endUIPass = function() end, + worldViewSize = function() return 160, 144 end, + setSGBZones = function() end, + } + StateStack:push(OverworldState, "OAKS_LAB", 5, 11, "up") + local ow = OverworldState + Game.overworld = ow + local ctx = { save = Game.save, game = Game, overworld = ow } + local finished = false + local function swapOaks() + Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") + Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1") + ow.emote = { frames = 3, onDone = function() + ow:scriptMove(ow.player, "up", 8, function() finished = true end) + end } + end + ow:scriptMove(ow:npcByIndex(8), "up", 3, swapOaks) + for _ = 1, 600 do + ow:update(1) + if finished then break end + end + check(finished, "held-UP lab walk-in completes") + eq(ow.player.cellX, 5, "held-UP walk-in x stays in the aisle") + eq(ow.player.cellY, 3, "held-UP walk-in ends at (5,3), not on desk Oak") + local oak1 = ow:npcByIndex(5) + check(oak1 and not (oak1.cellX == ow.player.cellX and oak1.cellY == ow.player.cellY), + "player does not stack on desk Oak after walk-in") + for k, v in pairs(prev) do Game[k] = v end +end + +if fails > 0 then error(("parity_intro: %d/%d checks failed"):format(fails, total)) end +print(("parity_intro: %d checks passed"):format(total)) diff --git a/tests/parity_static.lua b/tests/parity_static.lua new file mode 100644 index 00000000..5cdc0028 --- /dev/null +++ b/tests/parity_static.lua @@ -0,0 +1,275 @@ +-- Parity test, static-encounter pre-battle text (the ~12 disguised +-- static wild battles plus the two Snorlax). +-- +-- asm sources: scripts/PowerPlant.asm, scripts/SeafoamIslandsB4F.asm, +-- scripts/VictoryRoad2F.asm, scripts/CeruleanCaveB1F.asm, +-- scripts/Route12.asm, scripts/Route16.asm and home/trainers.asm +-- (TalkToTrainer / EndTrainerBattle: after-battle text when the +-- EVENT_BEAT_* flag is set, flag + HideObject on any non-blackout end). +-- +-- Self-contained: run via `luajit tests/parity_static.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +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.PALLET_TOWN) then Data:load() end + +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +-- === 1) every static encounter resolves to a hand-ported talk script +-- whose text labels all exist in the generated text === +local ENCOUNTERS = { + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB1" }, + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB2" }, + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB3" }, + { "POWER_PLANT", "TEXT_POWERPLANT_ELECTRODE1" }, + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB4" }, + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB5" }, + { "POWER_PLANT", "TEXT_POWERPLANT_ELECTRODE2" }, + { "POWER_PLANT", "TEXT_POWERPLANT_VOLTORB6" }, + { "POWER_PLANT", "TEXT_POWERPLANT_ZAPDOS" }, + { "SEAFOAM_ISLANDS_B4F", "TEXT_SEAFOAMISLANDSB4F_ARTICUNO" }, + { "VICTORY_ROAD_2F", "TEXT_VICTORYROAD2F_MOLTRES" }, + { "CERULEAN_CAVE_B1F", "TEXT_CERULEANCAVEB1F_MEWTWO" }, + { "ROUTE_12", "TEXT_ROUTE12_SNORLAX" }, + { "ROUTE_16", "TEXT_ROUTE16_SNORLAX" }, +} +for _, e in ipairs(ENCOUNTERS) do + local script = mapScripts.talkScript(e[1], e[2]) + check(type(script) == "table", "registry resolves " .. e[1] .. "/" .. e[2]) + for _, row in ipairs(script or {}) do + if row[1] == "show_text" and row[2]:sub(1, 1) == "_" then + check(Data.text[row[2]] ~= nil, e[2] .. " text " .. row[2] .. " exists") + end + end +end + +-- === harness: run a talk script headless, recording texts + battles === +local shown, battles = {}, {} +local battleResult = "win" +local origShow, origStart = Commands.show_text, Commands.start_battle +Commands.show_text = function(ctx, textId, subs) + table.insert(shown, textId) + return origShow(ctx, textId, subs) +end +Commands.start_battle = function(ctx, kind, a, b) + table.insert(battles, { kind = kind, species = a, level = b }) + ctx.lastBattleResult = battleResult + ctx.lastCheck = battleResult == "win" +end + +local function fakeOw(mapId, label) + return { map = { id = mapId, def = { label = label } }, npcs = {}, entities = {} } +end + +local function runScript(mapId, label, textConst, npcName) + shown, battles = {}, {} + local script = mapScripts.talkScript(mapId, textConst) + local ow = fakeOw(mapId, label) + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = { name = npcName } }, overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 2000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +local function toggleOf(mapId, objName) + local t = Game.save.objectToggles + return t and t[mapId] and t[mapId][objName] +end + +-- run a snorlaxWake script (the flute-triggered wake+battle sequence, +-- NOT a talk script -- see data/scripts/story.lua) headless +local function runWakeScript(mapId, label, npcName) + shown, battles = {}, {} + local script = mapScripts.get(mapId).snorlaxWake.script + local ow = fakeOw(mapId, label) + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = { name = npcName } }, overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 2000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +-- === 2) Zapdos: "Gyaoo!" then the battle; fleeing still counts as +-- beaten (EndTrainerBattle sets EVENT_BEAT_ZAPDOS and hides the +-- object on any non-blackout end) === +Game.save = SaveData.newGame() +battleResult = "run" +check(runScript("POWER_PLANT", "PowerPlant", "TEXT_POWERPLANT_ZAPDOS", + "POWERPLANT_ZAPDOS"), "Zapdos script completes") +eq(#shown, 1, "Zapdos shows one text") +eq(shown[1], "_PowerPlantZapdosBattleText", "Zapdos pre-battle text is Gyaoo!") +eq(#battles, 1, "Zapdos starts one battle") +eq(battles[1].species, "ZAPDOS", "Zapdos battle species") +eq(battles[1].level, 50, "Zapdos battle level") +check(Flags.get(Game.save, "EVENT_BEAT_ZAPDOS"), "fled Zapdos still sets EVENT_BEAT_ZAPDOS") +eq(toggleOf("POWER_PLANT", "POWERPLANT_ZAPDOS"), false, "fled Zapdos object hidden") + +-- === 3) capture-state branch: with EVENT_BEAT_ZAPDOS already set, +-- TalkToTrainer prints the after-battle text and stops === +check(runScript("POWER_PLANT", "PowerPlant", "TEXT_POWERPLANT_ZAPDOS", + "POWERPLANT_ZAPDOS"), "beaten-Zapdos script completes") +eq(#shown, 1, "beaten Zapdos still shows the text") +eq(#battles, 0, "beaten Zapdos starts no battle") + +-- === 4) Mewtwo: "Mew!" then MEWTWO lv70; catching sets EVENT_BEAT_MEWTWO === +Game.save = SaveData.newGame() +battleResult = "caught" +check(runScript("CERULEAN_CAVE_B1F", "CeruleanCaveB1F", "TEXT_CERULEANCAVEB1F_MEWTWO", + "CERULEANCAVEB1F_MEWTWO"), "Mewtwo script completes") +eq(shown[1], "_MewtwoBattleText", "Mewtwo pre-battle text is Mew!") +eq(battles[1] and battles[1].species, "MEWTWO", "Mewtwo battle species") +eq(battles[1] and battles[1].level, 70, "Mewtwo battle level") +check(Flags.get(Game.save, "EVENT_BEAT_MEWTWO"), "caught Mewtwo sets EVENT_BEAT_MEWTWO") + +-- capture-state branch: talking again shows the text only +check(runScript("CERULEAN_CAVE_B1F", "CeruleanCaveB1F", "TEXT_CERULEANCAVEB1F_MEWTWO", + "CERULEANCAVEB1F_MEWTWO"), "beaten-Mewtwo script completes") +eq(#shown, 1, "beaten Mewtwo still shows Mew!") +eq(#battles, 0, "beaten Mewtwo starts no battle") + +-- blackout: no flag, object stays +Game.save = SaveData.newGame() +battleResult = "lose" +runScript("CERULEAN_CAVE_B1F", "CeruleanCaveB1F", "TEXT_CERULEANCAVEB1F_MEWTWO", + "CERULEANCAVEB1F_MEWTWO") +check(not Flags.get(Game.save, "EVENT_BEAT_MEWTWO"), "blackout leaves EVENT_BEAT_MEWTWO unset") +eq(toggleOf("CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MEWTWO"), nil, + "blackout leaves Mewtwo visible") + +-- === 5) Power Plant item-ball Voltorb: "Bzzzt!" then VOLTORB lv40 === +Game.save = SaveData.newGame() +battleResult = "win" +check(runScript("POWER_PLANT", "PowerPlant", "TEXT_POWERPLANT_VOLTORB1", + "POWERPLANT_VOLTORB1"), "Voltorb script completes") +eq(shown[1], "_PowerPlantVoltorbBattleText", "Voltorb pre-battle text is Bzzzt!") +eq(battles[1] and battles[1].species, "VOLTORB", "Voltorb battle species") +eq(battles[1] and battles[1].level, 40, "Voltorb battle level") +check(Flags.get(Game.save, "EVENT_BEAT_POWER_PLANT_VOLTORB_0"), + "Voltorb 1 sets EVENT_BEAT_POWER_PLANT_VOLTORB_0") +-- Electrode header offset (text_asm 4 -> Voltorb3TrainerHeader) +runScript("POWER_PLANT", "PowerPlant", "TEXT_POWERPLANT_ELECTRODE1", + "POWERPLANT_ELECTRODE1") +eq(battles[1] and battles[1].species, "ELECTRODE", "Electrode battle species") +eq(battles[1] and battles[1].level, 43, "Electrode battle level") +check(Flags.get(Game.save, "EVENT_BEAT_POWER_PLANT_VOLTORB_3"), + "Electrode 1 sets EVENT_BEAT_POWER_PLANT_VOLTORB_3") + +-- === 6) Snorlax (Route 12): talking always shows the sleeping line -- +-- even with the flute in the bag, talking never wakes it (that's +-- the item-use menu's job; see ItemEffects.lua/BagMenu.lua) === +Game.save = SaveData.newGame() +runScript("ROUTE_12", "Route12", "TEXT_ROUTE12_SNORLAX", "ROUTE12_SNORLAX") +eq(#shown, 1, "flute-less Snorlax shows one text") +eq(shown[1], "_Route12SnorlaxText", "flute-less Snorlax shows the sleeping line") +eq(#battles, 0, "flute-less Snorlax starts no battle") + +require("src.inventory.Bag").add(Game.save, "POKE_FLUTE", 1) +runScript("ROUTE_12", "Route12", "TEXT_ROUTE12_SNORLAX", "ROUTE12_SNORLAX") +eq(#shown, 1, "Snorlax with the flute in the bag still just shows one text") +eq(shown[1], "_Route12SnorlaxText", + "talking with the flute in the bag does NOT wake Snorlax (must USE it)") +eq(#battles, 0, "Snorlax with the flute in the bag starts no battle from talking") + +-- === 7) using the flute (data/scripts/story.lua's snorlaxWake, run by +-- ItemEffects.lua/BagMenu.lua's flute_wake path): woke-up text, +-- battle, then the calmed-down line when NOT caught +-- (Route12SnorlaxPostBattleScript's wBattleResult ~= $2) === +Game.save = SaveData.newGame() +battleResult = "win" +check(runWakeScript("ROUTE_12", "Route12", "ROUTE12_SNORLAX"), + "Snorlax wake script completes") +eq(#shown, 2, "beaten Snorlax shows two texts") +eq(shown[1], "_Route12SnorlaxWokeUpText", "Snorlax woke-up text first") +eq(shown[2], "_Route12SnorlaxCalmedDownText", "calmed-down text when not caught") +eq(battles[1] and battles[1].species, "SNORLAX", "Snorlax battle species") +eq(battles[1] and battles[1].level, 30, "Snorlax battle level") +check(Flags.get(Game.save, "EVENT_BEAT_ROUTE12_SNORLAX"), "EVENT_BEAT_ROUTE12_SNORLAX set") +eq(toggleOf("ROUTE_12", "ROUTE12_SNORLAX"), false, "Snorlax object hidden") + +-- caught: no calmed-down line (cp $2 / jr z, .caught_snorlax) +Game.save = SaveData.newGame() +battleResult = "caught" +runWakeScript("ROUTE_16", "Route16", "ROUTE16_SNORLAX") +eq(#shown, 1, "caught Snorlax shows only the woke-up text") +eq(shown[1], "_Route16SnorlaxWokeUpText", "Route 16 woke-up text") +check(Flags.get(Game.save, "EVENT_BEAT_ROUTE16_SNORLAX"), "EVENT_BEAT_ROUTE16_SNORLAX set") + +-- blackout: HideObject ran BEFORE the battle, so Snorlax is gone anyway, +-- but the beat flag stays unset (Route16ResetScripts path) +Game.save = SaveData.newGame() +battleResult = "lose" +runWakeScript("ROUTE_16", "Route16", "ROUTE16_SNORLAX") +eq(#shown, 1, "blackout Snorlax shows only the woke-up text") +check(not Flags.get(Game.save, "EVENT_BEAT_ROUTE16_SNORLAX"), + "blackout leaves EVENT_BEAT_ROUTE16_SNORLAX unset") +eq(toggleOf("ROUTE_16", "ROUTE16_SNORLAX"), false, + "Snorlax hidden even after a blackout (pre-battle HideObject)") + +-- === 8) ItemEffects.lua's POKE_FLUTE field-use branch: only wakes +-- Snorlax (flute_wake) when the player is on its route, hasn't +-- beaten it, and stands right next to it; otherwise it's a no-op +-- flute_field (ItemUsePokeFlute / Route12SnorlaxFluteCoords) === +local ItemEffects = require("src.inventory.ItemEffects") + +local function fakeOwWithSnorlax(mapId, px, py, nx, ny) + return { + map = { id = mapId }, + player = { cellX = px, cellY = py }, + npcs = { { def = { name = mapId == "ROUTE_12" and "ROUTE12_SNORLAX" + or "ROUTE16_SNORLAX" }, cellX = nx, cellY = ny } }, + } +end + +Game.save = SaveData.newGame() +require("src.inventory.Bag").add(Game.save, "POKE_FLUTE", 1) +local owAdjacent = fakeOwWithSnorlax("ROUTE_12", 9, 62, 10, 62) -- west neighbor +local result, _, extra = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owAdjacent) +eq(result, "flute_wake", "using the flute next to Snorlax wakes it") +eq(extra and extra.mapId, "ROUTE_12", "flute_wake reports the route") + +local owFar = fakeOwWithSnorlax("ROUTE_12", 5, 5, 10, 62) -- far away +result = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owFar) +eq(result, "flute_field", "using the flute away from Snorlax has no effect") + +Flags.set(Game.save, "EVENT_BEAT_ROUTE12_SNORLAX") +result = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owAdjacent) +eq(result, "flute_field", "an already-beaten Snorlax doesn't wake again") + +-- restore the real commands for later suites +Commands.show_text = origShow +Commands.start_battle = origStart +Game.save = SaveData.newGame() + +print(("parity static: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-static assertion(s) failed") end diff --git a/tests/parity_tilt.lua b/tests/parity_tilt.lua new file mode 100644 index 00000000..c3049b6a --- /dev/null +++ b/tests/parity_tilt.lua @@ -0,0 +1,228 @@ +-- Parity test, overworld tilt mode. +-- Unit-tests src/render/Tilt.lua headless under the love stub: the +-- cycle/tween state machine (OFF/15/35/50), the free-roam input gate, +-- the groundPoint projection contract (identity at angle 0, monotonic +-- depth at a non-zero angle) and the world-view growth that keeps the +-- tilted plane covering the window. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +-- === assertions === + +local Tilt = require("src.render.Tilt") + +-- clean slate +Tilt.reset() +eq(Tilt.level, 0, "tilt starts at level 0 (OFF)") +eq(Tilt.enabled, false, "tilt starts disabled") +eq(Tilt.angle, 0, "tilt starts flat") +check(not Tilt.active(), "tilt inactive while flat and disabled") + +-- cycle ON to 15° and run the ~0.25s ease-in tween to completion +Tilt.cycle() +eq(Tilt.level, 1, "first cycle selects 15°") +eq(Tilt.enabled, true, "cycle enables tilt") +check(Tilt.active(), "tilt active immediately after enabling") +eq(Tilt.TARGET_ANGLE, math.rad(15), "target is 15 degrees") +Tilt.update(0.05) +check(Tilt.angle > 0 and Tilt.angle < Tilt.TARGET_ANGLE, + "tilt eases in to a partial angle") +for _ = 1, 20 do Tilt.update(0.05) end +check(math.abs(Tilt.angle - Tilt.TARGET_ANGLE) < 1e-9, + "tilt reaches the 15° target") + +-- cycle through 35 and 50 +Tilt.cycle() +eq(Tilt.level, 2, "second cycle selects 35°") +for _ = 1, 20 do Tilt.update(0.05) end +check(math.abs(Tilt.angle - math.rad(35)) < 1e-9, "tilt reaches 35°") +Tilt.cycle() +eq(Tilt.level, 3, "third cycle selects 50°") +for _ = 1, 20 do Tilt.update(0.05) end +check(math.abs(Tilt.angle - math.rad(50)) < 1e-9, "tilt reaches 50°") + +-- cycle back to OFF and tween out +Tilt.cycle() +eq(Tilt.level, 0, "fourth cycle returns to OFF") +eq(Tilt.enabled, false, "cycle disables tilt") +check(Tilt.active(), "tilt still active while tweening out") +for _ = 1, 20 do Tilt.update(0.05) end +eq(Tilt.angle, 0, "tilt returns exactly to flat") +check(not Tilt.active(), "tilt inactive once fully tweened out") + +-- reset clears the state anytime, mid-tween +Tilt.cycle(); Tilt.update(0.1) +Tilt.reset() +eq(Tilt.enabled, false, "reset disables tilt") +eq(Tilt.level, 0, "reset zeroes the level") +eq(Tilt.angle, 0, "reset zeroes the angle") +eq(Tilt.t, 1, "reset leaves tween settled") +check(not Tilt.active(), "reset makes tilt inactive") + +-- toggle is an alias for cycle +Tilt.reset() +Tilt.toggle() +eq(Tilt.level, 1, "toggle advances one level like cycle") + +-- input gate mirrors survey zoom (free-roam overworld only) +local ow = { transitioning = false } +check(Tilt.gateOK(ow, ow), "gate open while free-roaming the overworld") +check(not Tilt.gateOK(nil, ow), "gate closed with no active state") +check(not Tilt.gateOK({}, ow), "gate closed when top is not the overworld") +ow.transitioning = true +check(not Tilt.gateOK(ow, ow), "gate closed during a transition") +ow.transitioning = false +ow.runner = { isRunning = function() return true end } +check(not Tilt.gateOK(ow, ow), "gate closed while a script runs") + +-- groundPoint is the exact identity at angle 0 +Tilt.reset() +local vw, vh = 240, 160 +local gx, gy, gsc = Tilt.groundPoint(37, 91, vw, vh) +eq(gx, 37, "groundPoint identity X at angle 0") +eq(gy, 91, "groundPoint identity Y at angle 0") +eq(gsc, 1, "groundPoint identity depthScale at angle 0") + +-- at 50°: focus row fixed, above recedes, below approaches +Tilt.setLevel(3) +Tilt.angle = Tilt.TARGET_ANGLE +Tilt.t = 1 +local _, _, scMid = Tilt.groundPoint(vw / 2, vh / 2, vw, vh) +check(math.abs(scMid - 1) < 1e-9, "depthScale is 1 on the focus row") +local _, _, scTop = Tilt.groundPoint(vw / 2, vh * 0.25, vw, vh) +local _, _, scBot = Tilt.groundPoint(vw / 2, vh * 0.75, vw, vh) +check(scTop < 1, "rows above centre recede (depthScale < 1)") +check(scBot > 1, "rows below centre approach (depthScale > 1)") +do + local last, mono = -1, true + for row = 0, vh, 16 do + local _, _, sc = Tilt.groundPoint(vw / 2, row, vw, vh) + if sc <= last then mono = false end + last = sc + end + check(mono, "depthScale increases monotonically top to bottom") +end + +-- the projected corners still centre on the canvas centre (u = 0 point is +-- fixed) and carry their depthScale through as the mesh's per-vertex q +local corners = Tilt.meshCorners(vw, vh) +eq(#corners, 4, "meshCorners yields a 4-vertex quad") +eq(#corners[1], 5, "each corner is {sx, sy, u, v, depthScale}") + +-- view-size growth: none when flat, grows (>= ~1/cos) while tilted +Tilt.reset() +eq(Tilt.viewGrowth(), 1, "no view growth while flat") +Tilt.setLevel(3) +Tilt.angle = Tilt.TARGET_ANGLE +Tilt.t = 1 +check(Tilt.viewGrowth() >= 1 / math.cos(Tilt.TARGET_ANGLE) - 1e-9, + "view grows at least ~1/cos(angle) while tilted") + +local Renderer = require("src.render.Renderer") +Tilt.reset() +local baseW, baseH = Renderer:worldViewSize() +Tilt.setLevel(3) +Tilt.angle = Tilt.TARGET_ANGLE +Tilt.t = 1 +local tiltW, tiltH = Renderer:worldViewSize() +check(tiltH > baseH, "world view grows vertically while tilted") +check(tiltW >= baseW, "world view does not shrink horizontally while tilted") +Tilt.reset() + +-- === upright billboard pass ==================== +-- The reworked model tilts ONLY the ground. A standing thing draws upright +-- and UNSCALED -- pixel-identical to flat -- and the sole thing tilt changes +-- is its on-screen anchor: OverworldState:billboard slides the flat foot +-- (fx, fy) to where Tilt.groundPoint projects it, with a single translate and +-- NO scale (depthScale is ignored for sizing). We record the transform ops +-- (colors = nil keeps it shader-free) to observe that one translate = the +-- projected-minus-flat offset, and that scale is never touched. +local OW = require("src.world.OverworldController") +local g = love.graphics +local realPush, realPop, realT, realS = g.push, g.pop, g.translate, g.scale +local rec +g.push = function() end +g.pop = function() end +g.translate = function(x, y) if rec then rec.t[#rec.t + 1] = { x, y } end end +g.scale = function(x, y) if rec then rec.s[#rec.s + 1] = { x, y } end end +local function record(fx, fy, bw, bh) + rec = { t = {}, s = {} } + OW.billboard({}, fx, fy, bw, bh, nil, false, function() end) +end + +-- Billboards at the mild 15° level: approaching rows still project lower +-- (at steeper angles cos foreshortening can outweigh perspective growth). +Tilt.reset(); Tilt.setLevel(1); Tilt.angle = Tilt.TARGET_ANGLE; Tilt.t = 1 +local bw, bh = 240, 160 + +-- the billboard never scales -- only the ground tilts +local function noScale() return #rec.s == 0 end + +-- a foot on the focus row (viewport centre) is anchored unmoved (offset 0) +record(bw / 2, bh / 2, bw, bh) +eq(#rec.t, 1, "billboard emits a single translate (no scale, no re-origin)") +check(math.abs(rec.t[1][1]) < 1e-9 and math.abs(rec.t[1][2]) < 1e-9, + "billboard leaves a centre foot unmoved") +check(noScale(), "billboard never scales a centre foot (only the ground tilts)") + +-- a foot below centre: the translate slides it to exactly its groundPoint, +-- unscaled; it lands lower on screen (approaching row) but keeps its size +record(bw / 2, bh * 0.75, bw, bh) +local ex, ey = Tilt.groundPoint(bw / 2, bh * 0.75, bw, bh) +check(math.abs(rec.t[1][1] - (ex - bw / 2)) < 1e-9 + and math.abs(rec.t[1][2] - (ey - bh * 0.75)) < 1e-9, + "billboard slides a below-centre foot onto its groundPoint") +check(ey > bh * 0.75, "a below-centre foot projects lower (approaching row)") +check(noScale(), "billboard never scales a below-centre foot") + +-- a foot above centre is a receding row: it compresses toward the focus +-- centre (its projected y drifts down toward centre) but never past it, +-- and is still drawn unscaled +record(bw / 2, bh * 0.25, bw, bh) +local _, ay = Tilt.groundPoint(bw / 2, bh * 0.25, bw, bh) +check(ay > bh * 0.25 and ay < bh / 2, + "an above-centre foot recedes toward the focus row (compresses inward)") +check(noScale(), "billboard never scales an above-centre foot") + +rec = nil +g.push, g.pop, g.translate, g.scale = realPush, realPop, realT, realS + +-- the upright canvas plumbing: flat frames never touch it, tilt frames +-- allocate one matching the world view for endFrame to composite +Tilt.reset() +Renderer:init() +Renderer:beginFrame(true) +eq(Renderer.uprightActive, false, "upright pass inactive on a fresh frame") +Renderer:beginWorldPass() +eq(Renderer.uprightActive, false, "world pass alone leaves the upright pass off") +Renderer:beginUprightPass() +eq(Renderer.uprightActive, true, "beginUprightPass activates the upright pass") +local uw, uh = Renderer.uprightCanvas:getWidth(), Renderer.uprightCanvas:getHeight() +local vpw, vph = Renderer:worldViewSize() +local M = Renderer.UPRIGHT_MARGIN +check(uw == vpw + 2 * M and uh == vph + 2 * M, + "upright canvas is the world view grown by the edge margin on all sides") +Renderer:endUprightPass() +Tilt.reset() + +-- === Ground-only revision: buildings/trees/fences/signs are map tiles, so +-- they draw into the ground canvas and tilt with it like grass or paths -- +-- no per-tileset classification or per-structure extraction is needed. A +-- real map's renderer must never carry the (now removed) upright-structure +-- extraction machinery. +local Data = require("src.core.Data") +Data:load() +local MapLoader = require("src.world.MapLoader") +MapLoader.clearCache() +local pallet = MapLoader.load(Data, "PALLET_TOWN") +check(pallet.renderer.structures == nil, + "TileRenderer no longer extracts upright structures") +check(pallet.renderer.drawTilt == nil and pallet.renderer.drawTiltMapOnly == nil, + "TileRenderer no longer has a separate tilt ground draw path") +MapLoader.clearCache() + +print(("parity tilt: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-tilt assertion(s) failed") end diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua new file mode 100644 index 00000000..37e90b8b --- /dev/null +++ b/tests/parity_trade_gift.lua @@ -0,0 +1,281 @@ +-- Parity test, custom-flag equivalence audit: in-game trades +-- (EVENT_TRADED_*) and the Celadon Eevee gift (EVENT_GOT_EEVEE). +-- +-- asm sources: +-- engine/events/in_game_trades.asm (DoInGameTradeDialogue / +-- InGameTrade_DoTrade: wCompletedInGameTradeFlags FLAG_TEST before +-- the offer, FLAG_SET on completion; party-menu pick; NoTrade on +-- decline/cancel, WrongMon on species mismatch; ConnectCable -> +-- anim -> TradedFor -> Thanks; received mon keeps the sent level +-- and joins at the party's end) +-- data/events/trades.asm (TradeMons: give/get/dialogset/nickname) +-- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with +-- no confirm prompt; HideObject on success; ball stays if +-- GivePokemon fails with party+box full) +-- scripts/SSAnne2F.asm (rival ambush gated by wSSAnne2FCurScript -> +-- port flag EVENT_BEAT_SS_ANNE_RIVAL) +-- +-- Self-contained: run via `luajit tests/parity_trade_gift.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +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.PALLET_TOWN) then Data:load() end + +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local Pokemon = require("src.pokemon.Pokemon") +local PartyMenu = require("src.ui.PartyMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +-- === 1) TradeMons table parity (data/events/trades.asm order, +-- 1-based; dialogset 1/2/3 = CASUAL/EVOLUTION/HAPPY) === +local TRADEMONS = { + { "NIDORINO", "NIDORINA", 1, "TERRY" }, + { "ABRA", "MR_MIME", 1, "MARCEL" }, + { "BUTTERFREE", "BEEDRILL", 3, "CHIKUCHIKU" }, -- unused in pokered + { "PONYTA", "SEEL", 1, "SAILOR" }, + { "SPEAROW", "FARFETCHD", 3, "DUX" }, + { "SLOWBRO", "LICKITUNG", 1, "MARC" }, + { "POLIWHIRL", "JYNX", 2, "LOLA" }, + { "RAICHU", "ELECTRODE", 2, "DORIS" }, + { "VENONAT", "TANGELA", 3, "CRINKLES" }, + { "NIDORAN_M", "NIDORAN_F", 3, "SPOT" }, +} +eq(#Data.field.trades, 10, "field.trades has all 10 TradeMons rows") +for i, want in ipairs(TRADEMONS) do + local t = Data.field.trades[i] or {} + check(t.give == want[1] and t.get == want[2] + and (t.dialogset or 1) == want[3] and t.nickname == want[4], + ("trades[%d] = %s->%s set %d %q"):format(i, want[1], want[2], want[3], want[4])) +end + +-- === 2) every wired trade row uses the right index and a unique +-- EVENT_TRADED_* flag (wCompletedInGameTradeFlags bit per +-- TRADE_FOR_* constant); index 3 (CHIKUCHIKU) stays unused === +local WIRED = { -- 1-based trade index -> port flag + [1] = "EVENT_TRADED_NIDORINO_FOR_NIDORINA", + [2] = "EVENT_TRADED_ABRA_FOR_MR_MIME", + [4] = "EVENT_TRADED_PONYTA_FOR_SEEL", + [5] = "EVENT_TRADED_SPEAROW_FOR_FARFETCHD", + [6] = "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG", + [7] = "EVENT_TRADED_POLIWHIRL_FOR_JYNX", + [8] = "EVENT_TRADED_RAICHU_FOR_ELECTRODE", + [9] = "EVENT_TRADED_VENONAT_FOR_TANGELA", + [10] = "EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F", +} +-- (init.lua's registry MERGES talk tables in place, so the same script +-- rows are reachable through several story/flavor modules -- dedupe by +-- map + text constant, which is what the player can actually reach) +local seen, sites = {}, {} +for _, modname in ipairs({ "data.scripts.story", "data.scripts.story2", + "data.scripts.story3", "data.scripts.story4", + "data.scripts.story5", "data.scripts.story6", + "data.scripts.story7", "data.scripts.flavor_all" }) do + for mapId, m in pairs(require(modname)) do + if type(m) == "table" and m.talk then + for const, s in pairs(m.talk) do + if type(s) == "table" and not sites[mapId .. "/" .. const] then + for _, row in ipairs(s) do + if type(row) == "table" and row[1] == "trade" then + local idx, flag = row[2], row[3] + sites[mapId .. "/" .. const] = true + check(WIRED[idx] == flag, + ("%s/%s: trade %s pairs with %s"):format( + mapId, const, tostring(idx), tostring(flag))) + check(not seen[idx], + ("trade index %s wired by only one NPC"):format(tostring(idx))) + seen[idx] = true + end + end + end + end + end + end +end +for idx in pairs(WIRED) do + check(seen[idx], ("trade index %d is wired somewhere"):format(idx)) +end +check(not seen[3], "unused CHIKUCHIKU trade (index 3) stays unwired") + +-- === harness: run a talk script headless, recording show_text ids === +local shown = {} +local origShow = Commands.show_text +Commands.show_text = function(ctx, textId, subs) + table.insert(shown, textId) + return origShow(ctx, textId, subs) +end + +-- pressFn returns the Input.pressed table for this frame (default: A) +local function runScript(mapId, textConst, pressFn) + shown = {} + local script = mapScripts.talkScript(mapId, textConst) + local ow = { map = { id = mapId, def = { label = mapId } }, + npcs = {}, entities = {} } + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = {}, facePlayer = function() end }, + overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 3000 do + guard = guard + 1 + Input.pressed = pressFn and pressFn() or { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +local function shownIs(want, msg) + local got = table.concat(shown, ",") + eq(got, table.concat(want, ","), msg) +end + +local function toggleOf(mapId, objName) + local t = Game.save.objectToggles + return t and t[mapId] and t[mapId][objName] +end + +-- press B when `class` is on top of the stack, A otherwise +local function bOn(class) + return function() + if getmetatable(StateStack:top()) == class then return { b = true } end + return { a = true } + end +end + +local MARCEL = { "ROUTE_2_TRADE_HOUSE", "TEXT_ROUTE2TRADEHOUSE_GAMEBOY_KID" } + +-- === 3) trade success: WannaTrade -> yes -> pick ABRA -> ConnectCable +-- -> anim -> TradedFor -> Thanks; MR_MIME joins at the party's +-- end with the sent level + nickname; flag set === +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "ABRA", 10), Pokemon.new(Data, "PIDGEY", 7) } +check(runScript(MARCEL[1], MARCEL[2]), "MARCEL trade script completes") +shownIs({ "_WannaTrade1Text", "_ConnectCableText", "_TradedForText", "_Thanks1Text" }, + "trade success text sequence (casual dialogset)") +eq(#Game.save.party, 2, "party size unchanged by trade") +eq(Game.save.party[1].species, "PIDGEY", "remaining mon shifts up") +eq(Game.save.party[2].species, "MR_MIME", "received mon joins at the end") +eq(Game.save.party[2].level, 10, "received mon keeps the sent mon's level") +eq(Game.save.party[2].nickname, "MARCEL", "received mon keeps its TradeMons nickname") +check(Game.save.party[2].traded, "received mon is foreign (boosted exp)") +check(Flags.get(Game.save, "EVENT_TRADED_ABRA_FOR_MR_MIME"), + "trade sets its wCompletedInGameTradeFlags bit") +check(Game.save.pokedex.owned.MR_MIME, "received species registered owned") + +-- === 4) after the trade the same NPC only shows AfterTrade text and +-- the trade cannot repeat (FLAG_TEST short-circuit) === +Game.save.party = { Pokemon.new(Data, "ABRA", 10) } -- bait: another ABRA +check(runScript(MARCEL[1], MARCEL[2]), "post-trade script completes") +shownIs({ "_AfterTrade1Text" }, "completed trade shows only AfterTrade text") +eq(Game.save.party[1].species, "ABRA", "no second trade happens") + +-- === 5) decline at the yes/no: NoTrade text, nothing else === +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "ABRA", 10) } +check(runScript(MARCEL[1], MARCEL[2], bOn(ChoiceBox)), "declined trade completes") +shownIs({ "_WannaTrade1Text", "_NoTrade1Text" }, "decline shows NoTrade text") +check(not Flags.get(Game.save, "EVENT_TRADED_ABRA_FOR_MR_MIME"), + "declined trade leaves the flag unset") +eq(Game.save.party[1].species, "ABRA", "declined trade keeps the party") + +-- === 6) backing out of the party menu also lands on NoTrade === +check(runScript(MARCEL[1], MARCEL[2], bOn(PartyMenu)), "cancelled pick completes") +shownIs({ "_WannaTrade1Text", "_NoTrade1Text" }, "party-menu cancel shows NoTrade text") +check(not Flags.get(Game.save, "EVENT_TRADED_ABRA_FOR_MR_MIME"), + "cancelled pick leaves the flag unset") + +-- === 7) offering the wrong species: WrongMon text, trade still open === +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "PIDGEY", 7) } +check(runScript(MARCEL[1], MARCEL[2]), "wrong-mon offer completes") +shownIs({ "_WannaTrade1Text", "_WrongMon1Text" }, "wrong species shows WrongMon text") +check(not Flags.get(Game.save, "EVENT_TRADED_ABRA_FOR_MR_MIME"), + "wrong species leaves the flag unset") +eq(Game.save.party[1].species, "PIDGEY", "wrong species keeps the party") + +-- === 8) dialogsets: LOLA (EVOLUTION -> set 2), DUX (HAPPY -> set 3) +-- pick the matching AfterTrade text family === +Game.save = SaveData.newGame() +Flags.set(Game.save, "EVENT_TRADED_POLIWHIRL_FOR_JYNX") +check(runScript("CERULEAN_TRADE_HOUSE", "TEXT_CERULEANTRADEHOUSE_GAMBLER"), + "LOLA post-trade script completes") +shownIs({ "_AfterTrade2Text" }, "LOLA uses the evolution dialogset") +Flags.set(Game.save, "EVENT_TRADED_SPEAROW_FOR_FARFETCHD") +check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL"), + "DUX post-trade script completes") +shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") + +-- === 9) Celadon Eevee: no confirm prompt, GotMonText, ball hidden === +local EEVEE_MAP, EEVEE_BALL = + "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" +local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" +Game.save = SaveData.newGame() +check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") +shownIs({ "_GotMonText" }, "Eevee gives immediately (no ask prompt)") +eq(#Game.save.party, 1, "Eevee joins the party") +eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE") +eq(Game.save.party[1].level, 25, "Eevee is level 25") +check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "EVENT_GOT_EEVEE bookkeeping set") +eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden") +check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned") + +-- re-interaction (only possible on old saves): silent, no second Eevee +check(runScript(EEVEE_MAP, EEVEE_TEXT), "post-gift script completes") +shownIs({}, "taken ball gives nothing and says nothing") +eq(#Game.save.party, 1, "no second Eevee") + +-- === 10) old-save self-heal: flag set but ball never hidden === +Game.save = SaveData.newGame() +Flags.set(Game.save, "EVENT_GOT_EEVEE") +check(runScript(EEVEE_MAP, EEVEE_TEXT), "old-save script completes") +shownIs({}, "old save: silent") +eq(#Game.save.party, 0, "old save: no Eevee re-gift") +eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") + +-- === 11) GivePokemon failure (party + every box full): BoxIsFullText, +-- ball stays, flag unset -- the gift stays claimable === +Game.save = SaveData.newGame() +for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end +local Boxes = require("src.pokemon.Boxes") +Boxes.ensure(Game.save) +for b = 1, Boxes.COUNT do + for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end +end +check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-everything script completes") +shownIs({ "_BoxIsFullText" }, "full party+box shows BoxIsFullText") +check(not Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full party+box leaves flag unset") +eq(toggleOf(EEVEE_MAP, EEVEE_BALL), nil, "full party+box leaves the ball visible") + +-- === 12) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the +-- wSSAnne2FCurScript NOOP progression <-> the port's +-- EVENT_BEAT_SS_ANNE_RIVAL flag) === +local ss = require("data.scripts.story5").SS_ANNE_2F +Game.save = SaveData.newGame() +Flags.set(Game.save, "EVENT_BEAT_SS_ANNE_RIVAL") +eq(ss.onStep(Game, nil, 36, 8), false, "beaten rival: no ambush on (36,8)") +eq(ss.onStep(Game, nil, 37, 8), false, "beaten rival: no ambush on (37,8)") +Game.save = SaveData.newGame() +eq(ss.onStep(Game, nil, 30, 8), false, "unbeaten rival: no ambush off the trigger tiles") + +Commands.show_text = origShow + +print(("parity trade/gift: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity trade/gift assertion(s) failed") end diff --git a/tests/parity_trainer_sight.lua b/tests/parity_trainer_sight.lua new file mode 100644 index 00000000..ba071325 --- /dev/null +++ b/tests/parity_trainer_sight.lua @@ -0,0 +1,174 @@ +-- Parity test, trainer sight-line walk-up timing. +-- Oracle: home/trainers.asm CheckFightingMapTrainers + CheckForEngagingTrainers, +-- engine/overworld/trainer_sight.asm TrainerEngage/TrainerWalkUpToPlayer, +-- home/overworld.asm OverworldLoop/JoypadOverworld. +-- +-- In pokered, map scripts (and thus trainer detection) only run when +-- wWalkCounter == 0 -- i.e. the exact frame the player stands aligned on a +-- tile -- and they run inside JoypadOverworld BEFORE the loop's direction +-- handling. On detection, CheckFightingMapTrainers zeroes hJoyHeld and sets +-- wJoyIgnore = PAD_CTRL_PAD, so a held direction can never start another +-- step: the player freezes on the tile where they were spotted, the "!" +-- bubble shows (EmotionBubble, 60 frames), then the trainer walks +-- (distance - 1) steps and stops on the adjacent tile (TrainerWalkUpToPlayer +-- returns without a walk script when the pixel gap is exactly $10 = 1 tile). +-- +-- The port bug this guards against: on the detection frame, handleInput() +-- still ran (the `scripted` flag ignored self.engaging), so a held direction +-- bought the player one extra step during the "!" pause, landing the trainer +-- walk-up off by a block. +-- +-- Scenario map: ROUTE_3. Object 2 = SPRITE_YOUNGSTER at (10,6), STAY, +-- facing RIGHT, OPP_BUG_CATCHER (data/maps/objects/Route3.asm:22); its +-- header has sight range 2 (scripts/Route3.asm Route3TrainerHeader0). +-- Sight line: (11,6) and (12,6). +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() + +local TRAINER_INDEX = 2 -- Route 3 Youngster1, (10,6) facing right, range 2 + +local function freshOverworld(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() -- clear held state between scenarios + -- OW is a singleton state table (StateStack:push(OW, ...) re-enters the + -- same instance); in the game an engagement always resolves through the + -- battle's onDone, but these scenarios abandon it at the text box, so + -- scrub the in-flight engagement between scenarios + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "ROUTE_3", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == TRAINER_INDEX then trainer = npc end + end + return ow, trainer +end + +local function frame(ow) + Input:step() + ow:update(1 / 60) +end + +-- === (1) walk across the sight line with the d-pad held === +-- Player starts at (13,6) -- one tile OUTSIDE the range-2 line -- facing +-- left, and holds left. pokered: the step onto (12,6) completes, the very +-- next frame's map script spots the player and kills the held input; the +-- player must never leave (12,6). +do + local ow, trainer = freshOverworld(13, 6, "left") + check(trainer ~= nil and trainer.cellX == 10 and trainer.cellY == 6, + "Route 3 Youngster1 stands at (10,6)") + eq(trainer.facing, "right", "trainer faces right (STAY RIGHT)") + + -- range boundary: standing at distance 3 with range 2 must not engage + for _ = 1, 5 do frame(ow) end + check(not ow.engaging, "distance 3 > range 2: no engagement while standing") + + Input.state.left = true + -- walk one step onto (12,6); detection fires on the first standing frame + local guard = 0 + while not ow.engaging and guard < 60 do + guard = guard + 1 + frame(ow) + end + check(ow.engaging, "trainer engages once the player stands at distance 2") + eq(ow.player.cellX, 12, "detection tile X (spotted on (12,6))") + eq(ow.player.cellY, 6, "detection tile Y") + check(not ow.player.moving, "player is tile-aligned when spotted") + check(ow.emote ~= nil and ow.emote.npc == trainer, + "the ! bubble shows over the trainer on the detection frame") + eq(ow.emote and ow.emote.frames, 60, + "! bubble holds 60 frames (EmotionBubble DelayFrames 60)") + + -- keep holding left through the bubble + walk-up: the input lock + -- (wJoyIgnore = PAD_CTRL_PAD) means the player never moves again + local everMoved = false + guard = 0 + while Game.stack:top() == ow and guard < 400 do + guard = guard + 1 + frame(ow) + if ow.player.moving or ow.player.cellX ~= 12 or ow.player.cellY ~= 6 then + everMoved = true + end + end + check(not everMoved, + "held d-pad never buys another step after detection (input locked)") + check(Game.stack:top() ~= ow, "engagement reaches the pre-battle text box") + eq(ow.player.cellX, 12, "player still on the detection tile after walk-up") + -- TrainerWalkUpToPlayer: distance 2 -> (2 - 1) = 1 step, stop adjacent + eq(trainer.cellX, 11, "trainer walked distance-1 steps (10 -> 11)") + eq(trainer.cellY, 6, "trainer stayed on the sight row") + check(not trainer.moving, "trainer is tile-aligned next to the player") + eq(trainer.facing, "right", "trainer still faces the player") +end + +-- === (2) player already adjacent: no walk-up at all === +-- TrainerWalkUpToPlayer returns without writing a movement script when the +-- trainer is exactly one tile away (`cp $10 / ret z`). +do + local ow, trainer = freshOverworld(11, 6, "down") + local guard = 0 + while not ow.engaging and guard < 10 do + guard = guard + 1 + frame(ow) + end + check(ow.engaging, "adjacent player (distance 1) is spotted while standing") + guard = 0 + while Game.stack:top() == ow and guard < 200 do + guard = guard + 1 + frame(ow) + end + check(Game.stack:top() ~= ow, "adjacent engagement reaches the text box") + check(trainer.cellX == 10 and trainer.cellY == 6 and not trainer.moving, + "trainer never moves when the player is already adjacent") + check(ow.player.cellX == 11 and ow.player.cellY == 6, + "player unmoved in the adjacent case") +end + +-- === (3) whole-line engagement + range counting === +-- Standing anywhere on the line within range engages; range 2 means +-- exactly 2 tiles (CheckSpriteCanSeePlayer: distance <= range * 16 px). +do + local ow = freshOverworld(11, 6, "down") -- distance 1: in range + local guard = 0 + while not ow.engaging and guard < 10 do guard = guard + 1; frame(ow) end + check(ow.engaging, "distance 1 engages (line covers every tile up to range)") + + local ow2 = freshOverworld(12, 6, "down") -- distance 2: still in range + guard = 0 + while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end + check(ow2.engaging, "distance 2 engages (inclusive range)") + + local ow3 = freshOverworld(12, 5, "down") -- off the row: not aligned + for _ = 1, 10 do frame(ow3) end + check(not ow3.engaging, "off-row tile never engages (must be lined up)") + + local ow4 = freshOverworld(9, 6, "down") -- wrong side would be (9,6)... + for _ = 1, 10 do frame(ow4) end + check(not ow4.engaging, + "tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)") +end + +print(("parity trainer_sight: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " trainer-sight assertion(s) failed") end diff --git a/tests/parity_trashcans.lua b/tests/parity_trashcans.lua new file mode 100644 index 00000000..91eea4dc --- /dev/null +++ b/tests/parity_trashcans.lua @@ -0,0 +1,213 @@ +-- Parity test, Vermilion Gym trash can puzzle. +-- Self-contained: run via `luajit tests/parity_trashcans.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- Covers engine/events/hidden_events/vermilion_gym_trash.asm +-- (GymTrashScript + the GymTrashCans table, bug included) and +-- scripts/VermilionCity.asm (VermilionCity_Script +-- .setFirstLockTrashCanIndex): +-- * the first-lock can is rolled on EVERY Vermilion City map load +-- (Random & $0e -> a random even can), not lazily in the gym +-- * the second-lock can comes from the GymTrashCans row for the +-- first can: `mask AND random-byte` minus 1 indexes the candidate +-- bytes, so a zero AND underflows ($ff) into the bank's zero +-- padding and lands the switch in can 0 regardless (the documented +-- bug); mask 2 can only reach candidate 2, mask 4 only candidate 4 +-- * a wrong second can resets EVENT_1ST_LOCK_OPENED and immediately +-- re-rolls the first can +-- * opening the second lock prints only VermilionGymTrashSuccessText3 +-- (SuccessText2 is unused in pokered) and opens the door block +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.PALLET_TOWN) then Data:load() end +local fails, total = 0, 0 +local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end + +local OW = require("src.world.OverworldController") +local SaveData = require("src.core.SaveData") +local story = require("data.scripts.story") + +-- trashCanSwitch closes over the module-locals `Game` and `TextBox` +-- (set during real boot); rewire them through the debug library, the +-- same trick as parity_D.lua. +local function getUpvalue(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + i = i + 1 + end +end +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local shown = {} +local textBoxStub = { + new = function(_, text, onDone) + shown[#shown + 1] = text + if onDone then onDone() end + return { text = text } + end, +} +local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox") +local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } } +check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired") +check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired") + +-- scripted love.math.random: feed(...) queues the next return values +local realRandom = love.math.random +local queue = {} +local function feed(...) queue = { ... } end +love.math.random = function(a, b) + if #queue > 0 then return table.remove(queue, 1) end + return realRandom(a, b) +end + +local replaced = {} +local fakeSelf = setmetatable({ + map = { id = "VERMILION_GYM" }, + replaceBlock = function(_, x, y, b) replaced[#replaced + 1] = { x = x, y = y, b = b } end, +}, { __index = OW }) + +local function fresh() + fakeGame.save = SaveData.newGame() + shown = {} + replaced = {} +end + +local t = Data.text + +-- === Vermilion City map load rolls (and re-rolls) the first-lock can === +do + fresh() + check(story.VERMILION_CITY.onEnter ~= nil, "VERMILION_CITY.onEnter registered") + feed(3) -- Random & $0e ported as random(0,7)*2 + story.VERMILION_CITY.onEnter(fakeGame, {}) + eq(fakeGame.save.trashPuzzle.first, 6, "map load rolls the first-lock can (even index)") + feed(0) + story.VERMILION_CITY.onEnter(fakeGame, {}) + eq(fakeGame.save.trashPuzzle.first, 0, "every map load re-rolls, not just the first") + -- the roll is unconditional: mid-second-stage state survives, only + -- the (unread) first index changes + fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true + fakeGame.save.trashPuzzle.second = 3 + feed(5) + story.VERMILION_CITY.onEnter(fakeGame, {}) + eq(fakeGame.save.trashPuzzle.first, 10, "re-roll happens even after the 1st lock opened") + check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "map load leaves EVENT_1ST_LOCK_OPENED alone") + eq(fakeGame.save.trashPuzzle.second, 3, "map load leaves the second-lock can alone") +end + +-- === wrong first can: plain trash text, nothing opens === +do + fresh() + fakeGame.save.trashPuzzle = { first = 0 } + fakeSelf:trashCanSwitch(2) + check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "wrong first can opens nothing") + eq(shown[#shown], t._VermilionGymTrashText, "wrong first can shows VermilionGymTrashText") +end + +-- === right first can: EVENT_1ST_LOCK_OPENED + GymTrashCans second roll === +-- can 0's row is `mask 2, candidates 1,3`: AND result 2 -> byte offset 1 +-- -> candidate 2 (=can 3); candidate 1 is unreachable with mask 2 +do + fresh() + fakeGame.save.trashPuzzle = { first = 0 } + feed(2) -- random byte: band(2, mask 2) = 2 + fakeSelf:trashCanSwitch(0) + check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED == true, "right can sets EVENT_1ST_LOCK_OPENED") + eq(fakeGame.save.trashPuzzle.second, 3, "mask 2 reaches only the 2nd candidate (can 3)") + eq(shown[#shown], t._VermilionGymTrashSuccessText1, "first lock shows SuccessText1") +end + +-- zero AND result: `dec a` underflows and the read lands in zero +-- padding -> the second switch is can 0 regardless of adjacency +do + fresh() + fakeGame.save.trashPuzzle = { first = 0 } + feed(13) -- band(13, mask 2) = 0 + fakeSelf:trashCanSwitch(0) + eq(fakeGame.save.trashPuzzle.second, 0, "zero AND puts the second switch in can 0 (the bug)") +end + +-- can 4's row is `mask 4, candidates 1,3,5,7`: only AND result 4 +-- (-> 4th candidate, can 7) or the can-0 bug are possible +do + fresh() + fakeGame.save.trashPuzzle = { first = 4 } + feed(4) -- band(4, mask 4) = 4 + fakeSelf:trashCanSwitch(4) + eq(fakeGame.save.trashPuzzle.second, 7, "mask 4 reaches only the 4th candidate (can 7)") + fresh() + fakeGame.save.trashPuzzle = { first = 4 } + feed(3) -- band(3, mask 4) = 0 + fakeSelf:trashCanSwitch(4) + eq(fakeGame.save.trashPuzzle.second, 0, "mask 4 with a zero AND falls into can 0") +end + +-- can 6's row is `mask 3, candidates 3,7,9`: results 1-3 map onto all +-- three candidates +do + for _, case in ipairs({ { 1, 3 }, { 2, 7 }, { 3, 9 }, { 4, 0 } }) do + fresh() + fakeGame.save.trashPuzzle = { first = 6 } + feed(case[1]) -- band(case[1], mask 3): 4 -> 0 (bug), else itself + fakeSelf:trashCanSwitch(6) + eq(fakeGame.save.trashPuzzle.second, case[2], + ("mask 3, AND=%d -> second can %d"):format(case[1] % 4, case[2])) + end +end + +-- === wrong second can: relock + IMMEDIATE first-can re-roll === +do + fresh() + fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true + fakeGame.save.trashPuzzle = { first = 0, second = 3 } + feed(5) -- the fail path's Random & $e -> can 10 + fakeSelf:trashCanSwitch(1) + check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "fail resets EVENT_1ST_LOCK_OPENED") + eq(fakeGame.save.trashPuzzle.first, 10, "fail immediately re-rolls the first can") + eq(fakeGame.save.trashPuzzle.second, nil, "fail clears the second-lock can") + eq(shown[#shown], t._VermilionGymTrashFailText, "fail shows VermilionGymTrashFailText") +end + +-- === right second can: puzzle done, door block opens, only Text3 === +do + fresh() + fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true + fakeGame.save.trashPuzzle = { first = 0, second = 3 } + fakeSelf:trashCanSwitch(3) + check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true, "second lock sets EVENT_2ND_LOCK_OPENED") + eq(shown[#shown], t._VermilionGymTrashSuccessText3, + "only SuccessText3 prints (SuccessText2 is unused in pokered)") + check(replaced[1] and replaced[1].x == 2 and replaced[1].y == 2 and replaced[1].b == 5, + "door block (2,2) replaced with the clear floor block") + -- solved puzzle: every can is plain trash from now on + fakeSelf:trashCanSwitch(0) + eq(shown[#shown], t._VermilionGymTrashText, "solved puzzle shows plain trash text") +end + +-- === legacy saves: pre-rewrite `opened1` migrates to the event flag === +do + fresh() + fakeGame.save.trashPuzzle = { first = 0, opened1 = true, second = 3 } + fakeSelf:trashCanSwitch(3) + check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true, + "legacy opened1 save still completes the puzzle") +end + +love.math.random = realRandom +setUpvalue(OW.trashCanSwitch, "TextBox", realTextBox) + +print(("parity trashcans: %d/%d passed"):format(total - fails, total)) +if fails > 0 then error(fails .. " parity-trashcans assertion(s) failed") end diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua new file mode 100644 index 00000000..8309ea3c --- /dev/null +++ b/tests/run_link_tests.lua @@ -0,0 +1,244 @@ +-- Link play tests: the loopback transport, the trade session state +-- machine (with a trade evolution), and a lockstep link battle driven +-- over the loopback. Runs headlessly under plain luajit: +-- luajit tests/run_link_tests.lua +-- Real networking uses lua-enet (bundled with LÖVE); when enet is +-- importable (inside LÖVE) an actual host/join pairing over UDP +-- localhost is exercised too, otherwise that section is skipped. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +math.randomseed(4242) + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end +local function eq(got, want, msg) + check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +local Data = require("src.core.Data") +Data:load() +local Pokemon = require("src.pokemon.Pokemon") +local Protocol = require("src.link.Protocol") + +-- ---------------------------------------------------------------- json +local Json = require("src.link.Json") +local msg = { type = "party", n = 3, ok = true, list = { 1, 2, 3 }, + name = "RED\"s" } +local rt = Json.decode(Json.encode(msg)) +eq(rt.type, "party", "json round trip type") +eq(rt.n, 3, "json round trip number") +eq(#rt.list, 3, "json round trip array") +eq(rt.name, 'RED"s', "json round trip escaping") + +-- ---------------------------------------------------------------- pack/unpack +local kadabra = Pokemon.new(Data, "KADABRA", 30) +local packed = Protocol.packMon(kadabra) +local unpacked = Protocol.unpackMon(Data, packed) +eq(unpacked.species, "KADABRA", "mon survives the wire") +eq(unpacked.level, 30, "level survives") +eq(unpacked.stats.hp, kadabra.stats.hp, "stats recomputed identically") +-- tampering is clamped +packed.level = 3000 +packed.dvs.attack = 99 +local clamped = Protocol.unpackMon(Data, packed) +eq(clamped.level, 100, "tampered level clamped") +eq(clamped.dvs.attack, 15, "tampered DV clamped") + +-- ---------------------------------------------------------------- transport +local Net = require("src.link.Net") + +-- loopback pair: the offline transport the tests (and headless luajit, +-- which has no enet) run the protocol over +local lbA, lbB = Net.loopbackPair() +check(lbA.paired and lbB.paired, "loopback pair starts paired") +lbA:send({ type = "hello", name = "RED", mode = "trade" }) +lbB:send({ type = "hello", name = "BLUE", mode = "trade" }) +lbA:update() +lbB:update() +local gotA, gotB = lbA:poll()[1], lbB:poll()[1] +eq(gotA and gotA.name, "BLUE", "loopback A received B's hello") +eq(gotB and gotB.name, "RED", "loopback B received A's hello") +check(#lbA:poll() == 0, "poll drains the inbox") +lbB:close() +lbB:send({ type = "bye" }) +lbA:update() +check(#lbA:poll() == 0, "a closed end sends nothing") + +-- real enet pairing over UDP localhost (only when lua-enet is present, +-- i.e. inside LÖVE; plain luajit skips this section) +if Net.available() then + local host = Net.new() + check(host:host(7807), "host opens a UDP port") + check(host.address ~= nil and host.address:match(":7807$") ~= nil, + "host advertises an address: " .. tostring(host.address)) + local guest = Net.new() + check(guest:join("127.0.0.1:7807"), "guest dials the address") + local spins = 0 + while not (host.paired and guest.paired) and spins < 500000 do + host:update() + guest:update() + spins = spins + 1 + end + check(host.paired and guest.paired, "both sides paired over enet") + + host:send({ type = "hello", name = "RED", mode = "trade" }) + guest:send({ type = "hello", name = "BLUE", mode = "trade" }) + local got = { host = nil, guest = nil } + spins = 0 + while (not got.host or not got.guest) and spins < 500000 do + host:update() + guest:update() + for _, m in ipairs(host:poll()) do got.host = m end + for _, m in ipairs(guest:poll()) do got.guest = m end + spins = spins + 1 + end + eq(got.host and got.host.name, "BLUE", "host received guest hello") + eq(got.guest and got.guest.name, "RED", "guest received host hello") + + -- disconnect is noticed + guest:close() + spins = 0 + while not host.closed and spins < 500000 do + host:update() + spins = spins + 1 + end + check(host.closed, "host notices the guest leaving") + host:close() + + -- joining a dead address errors out (short timeout for the test) + local reject = Net.new() + reject.joinTimeout = 0.5 + reject:join("127.0.0.1:7809") + local t0 = os.clock() + while not reject.error and os.clock() - t0 < 30 do + reject:update() + end + check(reject.error ~= nil, "unanswered join reports an error") + reject:close() +else + print("skip real enet pairing (lua-enet not available under this interpreter)") +end + +-- ---------------------------------------------------------------- trade session +local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) } +local partyB = { Pokemon.new(Data, "MACHOKE", 32) } +local tA = Protocol.TradeSession.new(Data, partyA) +local tB = Protocol.TradeSession.new(Data, partyB) +tA:handle({ type = "party", mons = Protocol.packParty(partyB) }) +tB:handle({ type = "party", mons = Protocol.packParty(partyA) }) +eq(tA.stage, "picking", "trade session enters picking") +local pickA = tA:pick(1) -- gives KADABRA +local pickB = tB:pick(1) -- gives MACHOKE +tA:handle(pickB) +tB:handle(pickA) +eq(tA.stage, "confirming", "both picks -> confirming") +local cA = tA:confirm(true) +local cB = tB:confirm(true) +tA:handle(cB) +tB:handle(cA) +eq(tA.stage, "done", "trade completes") +local gotMon, evoTo = tA:apply(nil) +eq(gotMon.species, "MACHOKE", "A received Machoke") +eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)") +local gotMon2, evoTo2 = tB:apply(nil) +eq(gotMon2.species, "KADABRA", "B received Kadabra") +eq(evoTo2, "ALAKAZAM", "Kadabra -> Alakazam on trade") + +-- declined trades cancel +local tC = Protocol.TradeSession.new(Data, partyA) +tC:handle({ type = "party", mons = Protocol.packParty(partyB) }) +tC:pick(1) +tC:handle({ type = "pick", index = 1 }) +tC:confirm(true) +tC:handle({ type = "confirm", ok = false }) +eq(tC.stage, "cancelled", "declined trade cancels") + +-- ---------------------------------------------------------------- link battle (lockstep) +-- Both sides run the full engine locally on a shared seed; this drives +-- two simulations over a loopback and checks they agree. +local Input = require("src.core.Input") +Input:init() +require("src.render.Font").load(Data) + +local function makeFakeGame(leadSpecies) + local save = require("src.core.SaveData").newGame() + table.insert(save.party, Pokemon.new(Data, leadSpecies, 50)) + local stack = { list = {} } + function stack:push(s, ...) + table.insert(self.list, s) + if s.enter then s:enter(...) end + end + function stack:pop() table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } +end + +local LinkBattle = require("src.link.LinkBattle") +local gameA = makeFakeGame("CHARIZARD") +local gameB = makeFakeGame("BLASTOISE") +gameB.save.player.name = "BLUE" +-- each side's send lands in the other's inbox (json re-encoded like +-- the real wire) through Net's own loopback transport +local netA, netB = Net.loopbackPair() + +local packedA = Protocol.packParty(gameA.save.party) +local packedB = Protocol.packParty(gameB.save.party) +local seed = 987654321 + +local battleA = LinkBattle.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, theirName = "BLUE", seed = seed, +}) +local battleB = LinkBattle.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, theirName = "RED", seed = seed, +}) +local resA, resB = nil, nil +battleA.onFinish = function(r) resA = r end +battleB.onFinish = function(r) resB = r end +gameA.stack:push(battleA) +gameB.stack:push(battleB) +eq(battleA.kind, "link", "host battle is a link battle") +eq(battleA.enemy.mon.species, "BLASTOISE", "guest party became the host's enemy side") +eq(battleB.enemy.mon.species, "CHARIZARD", "host party became the guest's enemy side") + +-- drive both sides with mashed A (FIGHT -> first move) until done +local guard = 0 +while (resA == nil or resB == nil) and guard < 60000 do + guard = guard + 1 + Input.pressed = { a = true } + gameA.stack:update(1 / 60) + gameB.stack:update(1 / 60) +end +check(resA ~= nil and resB ~= nil, + ("lockstep battle completes on both sides (%s / %s)"):format( + tostring(resA), tostring(resB))) +check((resA == "win" and resB == "lose") or (resA == "lose" and resB == "win") + or (resA == "draw" and resB == "draw"), + "the two simulations agree on the outcome") +-- mirrored final state: my mon's HP on A equals A's mon HP as seen by B +eq(battleA.player.mon.hp, battleB.enemy.mon.hp, "host mon HP identical on both sides") +eq(battleA.enemy.mon.hp, battleB.player.mon.hp, "guest mon HP identical on both sides") +local leftoverMismatch = false +for turn, h in pairs(battleA.localHashes) do + if battleB.localHashes[turn] and battleB.localHashes[turn] ~= h then + leftoverMismatch = true + end +end +check(not leftoverMismatch, "no desync detected across the whole battle") +eq(gameA.save.money, 3000, "no prize money in link battles") +eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp, + "the real party is untouched (battle used clamped copies)") + +print(("\n%s"):format(failures == 0 and "ALL LINK TESTS PASSED" or failures .. " FAILURES")) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua new file mode 100644 index 00000000..adea3a15 --- /dev/null +++ b/tests/run_save_editor_tests.lua @@ -0,0 +1,388 @@ +-- Headless tests for tools/save-editor pure logic. +-- Run from repo root: lua5.4 tests/run_save_editor_tests.lua +-- (If lua5.4 is missing, use the same interpreter as tests/run_tests.lua.) +-- +-- Panel suites (Boxes/Items, Events/Dex, Map) live in separate files so each +-- can define its own harness without colliding with this runner: +-- tests/save_editor_task6_tests.lua +-- tests/save_editor_task7_tests.lua +-- tests/save_editor_task8_tests.lua +-- See tools/save-editor/README.md for the full list. + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love_stub + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor tests ==") + +local SaveData = require("src.core.SaveData") + +do + local data = SaveData.newGame() + data.player.map = "VIRIDIAN_CITY" + data.money = 1234 + data.flags.EVENT_GOT_POKEDEX = true + local encoded = SaveData.encode(data) + check(type(encoded) == "string", "encode returns string") + check(encoded:match("^return "), "encode starts with return") + local back, err = SaveData.decode(encoded) + check(back ~= nil, "decode ok: " .. tostring(err)) + eq(back.player.map, "VIRIDIAN_CITY", "decode map") + eq(back.money, 1234, "decode money") + check(back.flags.EVENT_GOT_POKEDEX == true, "decode flag") +end + +do + local bad, err = SaveData.decode("not lua {{{") + check(bad == nil, "decode rejects garbage") + check(type(err) == "string", "decode returns err string") +end + +local SaveIO = require("SaveIO") + +do + local path = SaveIO.defaultPath() + check(type(path) == "string" and #path > 0, "defaultPath nonempty") + check(path:match("save%.lua$"), "defaultPath ends with save.lua") + check(path:match("pokemon%-love2d"), "defaultPath uses game identity folder") + local uname = io.popen and io.popen("uname -s 2>/dev/null") + local sys = uname and uname:read("*l") or "" + if uname then uname:close() end + if sys == "Darwin" then + check(path:match("/LOVE/"), "defaultPath on macOS includes LOVE folder") + end + check(type(SaveIO.choosePath) == "function", "choosePath exists") +end + +do + local path = os.tmpname() .. "-gamesave.lua" + + local data = SaveData.newGame() + data.money = 42 + local ok, err = SaveIO.save(path, data) + check(ok, "SaveIO.save ok: " .. tostring(err)) + local f = io.open(path, "r") + check(f ~= nil, "save file exists") + if f then f:close() end + + local loaded, lerr = SaveIO.load(path) + check(loaded ~= nil, "SaveIO.load ok: " .. tostring(lerr)) + eq(loaded.money, 42, "SaveIO round trip money") + + data.money = 99 + ok, err = SaveIO.save(path, data) + check(ok, "second save ok: " .. tostring(err)) + loaded = assert(SaveIO.load(path)) + eq(loaded.money, 99, "second save money") + + local bakFiles = {} + local bakGlob = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null') + if bakGlob then + for line in bakGlob:lines() do + bakFiles[#bakFiles + 1] = line + end + bakGlob:close() + end + check(#bakFiles >= 1, "second save creates .bak-* sibling") + if #bakFiles >= 1 then + local bakData, berr = SaveIO.load(bakFiles[1]) + check(bakData ~= nil, "backup load ok: " .. tostring(berr)) + if bakData then eq(bakData.money, 42, "backup preserves previous money") end + end + + os.remove(path) + for _, bak in ipairs(bakFiles) do + os.remove(bak) + end +end + +local Catalog = require("Catalog") +local MonOps = require("MonOps") +local Data = require("src.core.Data") +Data:load() + +do + local cat = Catalog.build(Data) + check(#cat.species > 140, "species catalog size") + check(#cat.items > 100, "items catalog size") + check(#cat.moves > 150, "moves catalog size") + check(cat.species[1] < cat.species[2], "species sorted") +end + +do + local events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua") + check(#events > 50, "scraped events") + check(events[1]:match("^EVENT_"), "event prefix") +end + +do + local mon = MonOps.create(Data, "PIDGEY", 10) + eq(mon.species, "PIDGEY", "create species") + eq(mon.level, 10, "create level") + local hpBefore = mon.stats.hp + MonOps.setLevel(Data, mon, 20) + eq(mon.level, 20, "setLevel") + check(mon.stats.hp > hpBefore, "stats grew on level") + check(mon.hp <= mon.stats.hp, "hp clamped") + MonOps.setMove(Data, mon, 1, "GUST") + eq(mon.moves[1].id, "GUST", "setMove id") + check(mon.moves[1].pp > 0, "setMove pp") +end + +do + -- Magikarp is SLOW, Butterfree is MEDIUM_FAST, same level, different exp + local mon = MonOps.create(Data, "MAGIKARP", 20) + local expSlow = mon.exp + MonOps.setSpecies(Data, mon, "BUTTERFREE") + eq(mon.species, "BUTTERFREE", "setSpecies id") + eq(mon.level, 20, "setSpecies keeps level") + check(mon.exp ~= expSlow, "setSpecies resyncs exp for new growth curve") + eq(mon.exp, require("src.pokemon.Growth").expForLevel( + Data.pokemon.BUTTERFREE.growthRate, 20), "setSpecies exp matches curve") + MonOps.setDv(Data, mon, "attack", 15) + eq(mon.dvs.attack, 15, "setDv attack") + check(mon.dvs.hp >= 8, "syncHpDv sets high bit from odd attack") +end + +local State = require("State") + +do + local s = State.new() + eq(s.tab, "party", "State.new default tab") + eq(s.dirty, false, "State.new default dirty") + eq(s.selectedParty, 1, "State.new default selectedParty") + eq(s.selectedBox, 1, "State.new default selectedBox") + check(s.editingMon == nil, "State.new default editingMon nil") + State.markDirty(s) + check(s.dirty == true, "State.markDirty sets dirty") +end + +-- Party/MonEditor panels: drive Kit's immediate-mode hit-testing by placing +-- the "mouse" at the exact coordinates each panel draws its widgets at +-- (mirroring the layout constants in panels/{Party,MonEditor}.lua), so the +-- click handlers run for real without a live window. +local Kit = require("Kit") +local Party = require("Party") +local MonEditor = require("MonEditor") +local Pokemon = require("src.pokemon.Pokemon") + +do + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + local wartortle = MonOps.create(Data, "WARTORTLE", 20) + local pidgey = MonOps.create(Data, "PIDGEY", 5) + S.save.party = { wartortle, pidgey } + S.selectedParty = 1 + + local px, py = 12, 80 + + Kit.beginFrame(px + 10, py + 24 + 22 + 5, true) -- row 2 of the list + Party.draw(S, Kit, px, py) + eq(S.selectedParty, 2, "Party list click selects row") + check(S.editingMon == pidgey, "Party list click sets editingMon") + + Kit.beginFrame(px + 10, py + 200 + 10, true) -- Add button + Party.draw(S, Kit, px, py) + eq(#S.save.party, 3, "Party Add appends a mon") + check(S.dirty == true, "Party Add marks dirty") + S.dirty = false + + S.selectedParty = 3 + Kit.beginFrame(px + 110 + 10, py + 200 + 10, true) -- Remove button + Party.draw(S, Kit, px, py) + eq(#S.save.party, 2, "Party Remove drops selected mon") + + S.selectedParty = 2 + Kit.beginFrame(px + 220 + 10, py + 200 + 10, true) -- Move Up button + Party.draw(S, Kit, px, py) + eq(S.selectedParty, 1, "Party Move Up updates selection") + check(S.save.party[1] == pidgey, "Party Move Up swaps order") +end + +do + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + local mon = MonOps.create(Data, "WARTORTLE", 20) + S.editingMon = mon + + local mx, my = 640, 80 + local levelBefore = mon.level + local hpStatBefore = mon.stats.hp + + Kit.beginFrame(mx + 148 + 10, my + 84 + 10, true) -- "+1" level button + MonEditor.draw(S, Kit, mx, my) + eq(mon.level, levelBefore + 1, "MonEditor +1 level button") + check(mon.stats.hp >= hpStatBefore, "MonEditor level up recalcs stats") + check(S.dirty == true, "MonEditor level change marks dirty") + S.dirty = false + + local dvY = my + 154 + local attackBefore = mon.dvs.attack + Kit.beginFrame(mx + 160 + 5, dvY + 5, true) -- attack DV "+" button + MonEditor.draw(S, Kit, mx, my) + eq(mon.dvs.attack, math.min(15, attackBefore + 1), "MonEditor DV attack + button") + + local hpDvY = dvY + 4 * 30 + 6 + local movesY = hpDvY + 34 + local slot1Y = movesY + 24 + local moveBefore = mon.moves[1] and mon.moves[1].id + Kit.beginFrame(mx + 10, slot1Y + 10, true) -- move slot 1 + MonEditor.draw(S, Kit, mx, my) + check(mon.moves[1] ~= nil, "MonEditor move slot has a move after cycle") + check(mon.moves[1].id ~= moveBefore, "MonEditor move slot cycles to a different move") + + local actionsY = movesY + 24 + 4 * 30 + 10 + Kit.beginFrame(mx + 10, actionsY + 10, true) -- Reset moves to learnset + MonEditor.draw(S, Kit, mx, my) + local def = Data.pokemon[mon.species] + local learned = Pokemon.movesAtLevel(def, mon.level) + eq(#mon.moves, #learned, "MonEditor reset moves matches learnset size") + + Kit.beginFrame(mx + 10, actionsY + 38 + 10, true) -- Close + MonEditor.draw(S, Kit, mx, my) + check(S.editingMon == nil, "MonEditor Close clears editingMon") +end + +-- App.load corrupt-save vs missing-save (Important fix #2): App.load takes +-- an optional path override precisely so tests can drive this without +-- touching the real default save file. +local App = require("App") + +-- App.draw() sources its click state from App.mousepressed() + the mouse +-- position at draw time (not from a Kit.beginFrame call made by the test), +-- so simulating a click means moving the mouse and pressing before drawing. +local appMouseX, appMouseY = 0, 0 +love.mouse = { getPosition = function() return appMouseX, appMouseY end } + +local function clickApp(x, y) + appMouseX, appMouseY = x, y + App.mousepressed(x, y, 1) + App.draw() +end + +do + local tmpPath = os.tmpname() .. "-missing-save.lua" + os.remove(tmpPath) + + App.load(tmpPath) + local s = App.getState() + eq(s.loadError, false, "App.load missing-file: loadError stays false") + eq(s.allowSave, true, "App.load missing-file: allowSave stays true") + check(s.status:match("No save at") ~= nil, "App.load missing-file status mentions no save") +end + +do + local tmpPath = os.tmpname() .. "-corrupt-save.lua" + local f = io.open(tmpPath, "wb") + f:write("not valid lua {{{") + f:close() + + App.load(tmpPath) + local s = App.getState() + eq(s.loadError, true, "App.load corrupt-file: loadError set true") + eq(s.allowSave, false, "App.load corrupt-file: allowSave set false") + check(s.status:match("Corrupt save") ~= nil, "App.load corrupt-file status mentions corrupt save") + + -- Clicking Save while loadError is set must be a no-op: file on disk + -- (the corrupt real save) must not be overwritten by the stub. + clickApp(110 + 10, 6 + 10) -- Save button + local unchanged = io.open(tmpPath, "rb") + local contents = unchanged:read("*a") + unchanged:close() + eq(contents, "not valid lua {{{", "Save no-op leaves the corrupt file on disk untouched") + check(App.getState().status:match("disabled") ~= nil, "Save no-op reports a disabled status") + + -- Fixing the file and Reloading must re-enable Save. + local fixed = io.open(tmpPath, "wb") + fixed:write(SaveData.encode(SaveData.newGame())) + fixed:close() + clickApp(200 + 10, 6 + 10) -- Reload button + eq(App.getState().loadError, false, "Reload after fixing the file clears loadError") + eq(App.getState().allowSave, true, "Reload after fixing the file re-enables allowSave") + + os.remove(tmpPath) +end + +do + -- Optional fix: quit-confirmation re-arms once new edits land, so a + -- prior "press quit again" arming doesn't leak across separate edits. + local tmpPath = os.tmpname() .. "-quitarmed-save.lua" + os.remove(tmpPath) + App.load(tmpPath) + local s = App.getState() + s._quitArmed = true + s.tab = "items" + + clickApp(12 + 132 + 10, 80 + 22 + 10) -- Items panel "+10" money button + eq(App.getState()._quitArmed, false, "A fresh dirty edit resets _quitArmed") + + os.remove(tmpPath) +end + +do + -- Open... / App.openPath: switch to another save; dirty needs a second open. + local a = os.tmpname() .. "-open-a.lua" + local b = os.tmpname() .. "-open-b.lua" + local dataA = SaveData.newGame(); dataA.money = 111 + local dataB = SaveData.newGame(); dataB.money = 222 + assert(SaveIO.save(a, dataA)) + assert(SaveIO.save(b, dataB)) + + App.load(a) + eq(App.getState().save.money, 111, "openPath setup: loaded A") + eq(App.getState().path, a, "openPath setup: path is A") + + check(App.openPath(b) == true, "openPath clean switch succeeds") + eq(App.getState().path, b, "openPath updates path to B") + eq(App.getState().save.money, 222, "openPath loads B money") + eq(App.getState().dirty, false, "openPath clears dirty") + + App.getState().dirty = true + check(App.openPath(a) == false, "openPath dirty first call arms confirm") + eq(App.getState().path, b, "openPath dirty first call keeps current path") + check(App.getState().status:match("Unsaved changes") ~= nil, + "openPath dirty first call status warns") + check(App.openPath(a) == true, "openPath dirty second call proceeds") + eq(App.getState().path, a, "openPath dirty second call switches path") + eq(App.getState().save.money, 111, "openPath dirty second call loads A") + + check(App.openPath(b, true) == true, "openPath force=true skips arming") + eq(App.getState().path, b, "openPath force switches immediately") + + -- Drag-drop uses the File:getFilename() API. + local dropped = { getFilename = function() return a end } + App.filedropped(dropped) + eq(App.getState().path, a, "filedropped opens the dropped path") + + os.remove(a); os.remove(b) + for _, path in ipairs({ a, b }) do + local bak = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null') + if bak then + for line in bak:lines() do os.remove(line) end + bak:close() + end + end +end + +print(string.format("save editor tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/run_tests.lua b/tests/run_tests.lua new file mode 100644 index 00000000..3a6826bb --- /dev/null +++ b/tests/run_tests.lua @@ -0,0 +1,2368 @@ +-- Headless smoke/behavior tests. Run from the repo root after building +-- the generated data: +-- lua5.4 tests/run_tests.lua +-- +-- These exercise real generated data end-to-end: map collision, warps, +-- text, stats, damage, growth, type chart, encounters, scripts and the +-- battle loop -- everything except actual rendering. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +math.randomseed(12345) + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end + +local function eq(got, want, msg) + check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +-- ---------------------------------------------------------------- data +local Data = require("src.core.Data") +Data:load() +check(Data.maps.PALLET_TOWN ~= nil, "generated data loads") + +-- ---------------------------------------------------------------- map & collision +local MapLoader = require("src.world.MapLoader") +local pallet = MapLoader.load(Data, "PALLET_TOWN") +eq(pallet.widthCells, 20, "Pallet Town width in cells") +eq(pallet.heightCells, 18, "Pallet Town height in cells") + +-- known ground truth: the fence row below the houses is blocked, the +-- open plaza is walkable, house doors are warps on door tiles +check(pallet:isWalkableCell(5, 6), "spawn cell (5,6) walkable") +check(not pallet:isWalkableCell(4, 4), "house cell (4,4) blocked") +check(not pallet:isWalkableCell(0, 3), "west fence blocked") +check(pallet:isWalkableCell(5, 5), "Red's house door cell walkable") +check(pallet:isWarpTileCell(5, 5), "Red's house door is a door tile") +local w = pallet:warpAtCell(5, 5) +eq(w.def.destMap, "REDS_HOUSE_1F", "door warp goes to Red's house") + +-- signs +local sign = pallet:signAtCell(13, 13) +eq(sign.text, "TEXT_PALLETTOWN_OAKSLAB_SIGN", "Oak's lab sign at (13,13)") + +-- ---------------------------------------------------------------- warp resolution +local Warp = require("src.world.Warp") +local destMap, dx, dy = Warp.destination(Data, { destMap = "OAKS_LAB", destWarp = 2 }) +eq(destMap, "OAKS_LAB", "warp dest map") +eq(dx, 5, "Oak's Lab warp 2 x") +eq(dy, 11, "Oak's Lab warp 2 y") + +local lab = MapLoader.load(Data, "OAKS_LAB") +local lm, lx, ly = Warp.destination(Data, lab.def.warps[1], + { id = "PALLET_TOWN", x = 12, y = 11 }) +eq(lm, "PALLET_TOWN", "LAST_MAP warp returns to Pallet Town") +eq(lx, 12, "LAST_MAP x remembered") + +-- ---------------------------------------------------------------- text +check(Data.text._PalletTownSignText:find("PALLET TOWN", 1, true) ~= nil, + "sign text extracted") +local girl = Data:resolveText("PalletTown", "TEXT_PALLETTOWN_GIRL") +check(girl and girl:find("raising", 1, true) ~= nil, "girl text via TEXT_ pointer") +check(girl:find("POKéMON", 1, true) ~= nil, "#MON expanded to POKéMON") + +-- ---------------------------------------------------------------- font/textbox +local Font = require("src.render.Font") +Font.load(Data) +local codes = Font.encode("PALLET TOWN!") +eq(#codes, 12, "encode length") +eq(codes[1], 0x8F, "P glyph code") +eq(codes[7], 0x7F, "space glyph code") +eq(codes[12], 0xE7, "! glyph code") + +local TextBox = require("src.render.TextBox") +local pages = TextBox.paginate("I'm raising\nPOKéMON too!\fWhen they get\nstrong, they can\vprotect me!") +eq(#pages, 2, "two pages") +eq(#pages[1], 2, "page 1 has two lines") +eq(#pages[2], 3, "page 2 keeps scrolled line") + +-- ---------------------------------------------------------------- stats & growth +local Stats = require("src.pokemon.Stats") +local Growth = require("src.pokemon.Growth") +local bulba = Data.pokemon.BULBASAUR +local zeroDVs = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } +local s = Stats.calc(bulba, 5, zeroDVs) +-- hand-checked Gen 1 formulas +eq(s.hp, 19, "L5 Bulbasaur HP (0 DVs)") +eq(s.attack, 9, "L5 Bulbasaur Attack (0 DVs)") +eq(s.special, 11, "L5 Bulbasaur Special (0 DVs)") + +local maxDVs = { hp = 15, attack = 15, defense = 15, speed = 15, special = 15 } +local s100 = Stats.calc(Data.pokemon.MEWTWO, 100, maxDVs, + { hp = 65535, attack = 65535, defense = 65535, + speed = 65535, special = 65535 }) +eq(s100.hp, 415, "L100 Mewtwo max HP (known value)") +eq(s100.special, 406, "L100 Mewtwo max Special (known value)") + +-- stat exp bonus is a CEILING sqrt (CalcStat .statExpLoop finds the +-- smallest b with b*b >= statExp): statExp 130 -> b 12 -> bonus 3, +-- where a floor sqrt would give 11 -> 2. At L100 the bonus lands +-- unscaled: 49*2 + 3 + 5 = 106. +do + local sExpCeil = Stats.calc(bulba, 100, zeroDVs, { attack = 130 }) + eq(sExpCeil.attack, 106, "stat exp bonus uses ceil(sqrt) (statExp 130 -> +3)") +end + +eq(Growth.expForLevel("MEDIUM_SLOW", 5), 135, "medium slow exp at L5") +eq(Growth.expForLevel("MEDIUM_FAST", 10), 1000, "medium fast exp at L10") +eq(Growth.levelForExp("MEDIUM_FAST", 999), 9, "level from exp") + +-- ---------------------------------------------------------------- type chart +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +eq(TypeChart.effectiveness("WATER", { "FIRE" }), 20, "water vs fire 2x") +eq(TypeChart.effectiveness("ELECTRIC", { "GROUND" }), 0, "electric vs ground immune") +eq(TypeChart.effectiveness("GRASS", { "WATER", "POISON" }), 10, "grass vs water/poison neutral") +eq(TypeChart.effectiveness("WATER", { "GRASS", "DRAGON" }), 2, "water vs grass/dragon 0.25x") + +-- ---------------------------------------------------------------- damage +local Damage = require("src.battle.Damage") +local Pokemon = require("src.pokemon.Pokemon") +local ruleset = require("src.battle.rulesets.gen1_faithful") + +local function fixedMon(species, level) + local mon = Pokemon.new(Data, species, level) + mon.dvs = zeroDVs + mon.stats = Stats.calc(Data.pokemon[species], level, zeroDVs) + mon.hp = mon.stats.hp + return mon +end + +local function battler(mon, def) + return { mon = mon, def = def, stages = {}, name = def.id, + curStats = mon.stats, curTypes = def.types, curMoves = mon.moves } +end + +local atkMon, defMon = fixedMon("BULBASAUR", 5), fixedMon("RATTATA", 3) +local attacker = battler(atkMon, bulba) +local defender = battler(defMon, Data.pokemon.RATTATA) +local tackle = Data.moves.TACKLE +-- hand-computed: atk 9 (L5 Bulbasaur), def 7 (L3 Rattata, base 35): +-- floor(2*5/5)+2=4; floor(4*35*9/7 / 50)=floor(180/50)=3; +2=5; +-- no STAB (tackle is Normal, attacker Grass/Poison); max roll 255 -> 5 +local dmg = Damage.compute(ruleset, attacker, defender, tackle, + { rng = function() return 255 end, forceCrit = false }) +eq(dmg, 5, "deterministic Tackle damage (max roll)") +-- min roll 217: floor(5*217/255)=4 +dmg = Damage.compute(ruleset, attacker, defender, tackle, + { rng = function() return 217 end, forceCrit = false }) +eq(dmg, 4, "deterministic Tackle damage (min roll)") + +-- STAB + effectiveness: vine whip (grass 35) vs squirtle L5 +local sq = fixedMon("SQUIRTLE", 5) +local defSq = battler(sq, Data.pokemon.SQUIRTLE) +-- base: floor(2*5/5)+2=4; atk special 11 vs def special 10: +-- floor(floor(4*35*11/10)/50)=3; +2=5; STAB floor(5*3/2)=7; x2 type=14; max roll 14 +dmg = Damage.compute(ruleset, attacker, defSq, Data.moves.VINE_WHIP, + { rng = function() return 255 end, forceCrit = false }) +eq(dmg, 14, "Vine Whip STAB + super effective vs Squirtle") + +-- immunity +local gastly = fixedMon("GASTLY", 5) +dmg = Damage.compute(ruleset, attacker, battler(gastly, Data.pokemon.GASTLY), + tackle, { rng = function() return 255 end, forceCrit = false }) +eq(dmg, 0, "Normal vs Ghost immune") + +-- ---------------------------------------------------------------- encounters +local Encounter = require("src.world.Encounter") +local route1 = Data.encounters.ROUTE_1 +check(route1.grass.rate == 25 and #route1.grass.slots == 10, "Route 1 table shape") +local hit = Encounter.roll(route1, function(a, b) return 0 end) +eq(hit.species, "PIDGEY", "slot 1 is Pidgey L3") +eq(hit.level, 3, "slot 1 level") + +-- ---------------------------------------------------------------- trainers +local rival = Data.trainers.OPP_RIVAL1 +check(rival and #rival.parties >= 1, "Rival1 parties extracted") +eq(rival.parties[1][1].level, 5, "Rival1 first party is L5") + +-- ---------------------------------------------------------------- moves at level +local mv = Pokemon.movesAtLevel(Data.pokemon.RATTATA, 3) +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") + +-- ---------------------------------------------------------------- screens & focus energy +local reflDef = battler(fixedMon("RATTATA", 3), Data.pokemon.RATTATA) +reflDef.reflect = true +local dmgRefl = Damage.compute(ruleset, attacker, reflDef, tackle, + { rng = function() return 255 end, forceCrit = false }) +check(dmgRefl < 5, "Reflect halves physical damage (" .. dmgRefl .. " < 5)") + +-- gen1 focus energy bug: crit threshold quartered +local fe = battler(fixedMon("BULBASAUR", 5), bulba) +fe.focusEnergy = true +local critCount = 0 +for i = 0, 255 do + if Damage.critRoll(ruleset, fe, "TACKLE", function() return i end) then + critCount = critCount + 1 + end +end +eq(critCount, math.floor(math.floor(45 / 2) / 4), "Focus Energy bug quarters crit rate") + +-- ---------------------------------------------------------------- items +local ItemEffects = require("src.inventory.ItemEffects") +local save = require("src.core.SaveData").newGame() +local hurt = fixedMon("BULBASAUR", 5) +hurt.hp = 1 +local result = ItemEffects.use(Data, save, "POTION", hurt) +eq(result, "consumed", "potion consumed") +eq(hurt.hp, 19, "potion heals 20 capped at max (1 -> 19/19)") +hurt.status = "PSN" +result = ItemEffects.use(Data, save, "ANTIDOTE", hurt) +eq(hurt.status, nil, "antidote cures poison") +result = ItemEffects.use(Data, save, "BURN_HEAL", hurt) +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") +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") +local pikachu = fixedMon("PIKACHU", 10) +local r3 = ItemEffects.use(Data, save, "TM_TOXIC", pikachu) +check(r3 == "learn", "Pikachu can learn Toxic (in tmhm list)") +local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu) +eq(r4, "failed", "Pikachu can't learn Surf") +local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu) +eq(r5, "consumed", "Thunder Stone works on Pikachu") +eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu") + +-- ---------------------------------------------------------------- evolution data +local Evolution = require("src.pokemon.Evolution") +local wart = fixedMon("SQUIRTLE", 16) +eq(Evolution.pendingLevelEvo(Data, wart), "WARTORTLE", "Squirtle evolves at 16") +local low = fixedMon("SQUIRTLE", 15) +eq(Evolution.pendingLevelEvo(Data, low), nil, "no evolution below 16") + +-- ---------------------------------------------------------------- marts & trainer headers +local vmClerk = Data:textEntry("ViridianMart", "TEXT_VIRIDIANMART_CLERK") +check(vmClerk and vmClerk.mart and #vmClerk.mart == 4, "Viridian Mart sells 4 items") +eq(vmClerk.mart[1], "POKE_BALL", "Viridian Mart slot 1") +local nurse = Data:textEntry("ViridianPokecenter", "TEXT_VIRIDIANPOKECENTER_NURSE") +check(nurse and nurse.nurse == true, "Viridian nurse marked") +local hdr = Data:trainerHeader("Route3", 2) +check(hdr and hdr.range == 2 and hdr.event == "EVENT_BEAT_ROUTE_3_TRAINER_0", + "Route 3 trainer header extracted") +check(Data.text[hdr.battle] ~= nil, "trainer battle text resolves") +check(Data.text._PokemonCenterWelcomeText:find("CENTER", 1, true) ~= nil, + "engine strings extracted (nurse welcome)") + +-- ---------------------------------------------------------------- field data +check(#Data.field.ledges == 8, "8 ledge rules") +check(#Data.field.cutTreeSwaps == 9, "9 cut tree swaps") + +-- ---------------------------------------------------------------- story data +-- legendaries are static encounters, not trainers +local seafoam = Data.maps.SEAFOAM_ISLANDS_B4F +local articuno +for _, o in ipairs(seafoam.objects) do + if o.pokemon then articuno = o end +end +check(articuno and articuno.pokemon == "ARTICUNO" and articuno.level == 50, + "Articuno static encounter extracted") +-- the Silph Scope is an item ball in the hideout +local scope +for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do + if o.item == "SILPH_SCOPE" then scope = o end +end +check(scope ~= nil, "Silph Scope item ball extracted") +-- trades +eq(Data.field.trades[2].give, "ABRA", "trade 2 wants Abra") +eq(Data.field.trades[2].get, "MR_MIME", "trade 2 gives Mr. Mime") +eq(Data.field.trades[2].nickname, "MARCEL", "trade 2 nickname") +-- music song table (only when the full audio extraction ran) +if Data.audio and next(Data.audio.mapSongs) then + check(Data.audio.mapSongs.PALLET_TOWN == "Music_PalletTown", + "Pallet Town song mapped") + check(Data.audio.songs.Music_PalletTown ~= nil, "Pallet Town song rendered") +end + +-- Victory Road switch barriers: block (bx=3,by=4) on 2F must start +-- blocked and open up when replaced with $15 (scripts/VictoryRoad2F.asm) +local vr2 = MapLoader.load(Data, "VICTORY_ROAD_2F") +local beforeBlock = vr2:blockAt(3, 4) +local cellBlockedBefore = not vr2:isWalkableCell(7, 9) +vr2:setBlock(3, 4, 0x15) +local cellOpenAfter = vr2:isWalkableCell(7, 9) +vr2:setBlock(3, 4, beforeBlock) -- restore for other tests +check(cellBlockedBefore and cellOpenAfter, + ("VR2F switch1 barrier opens (block %d -> $15, blocked %s open %s)") + :format(beforeBlock, tostring(cellBlockedBefore), tostring(cellOpenAfter))) + +-- ---------------------------------------------------------------- polish systems +-- dex entries +eq(Data.pokemon.BULBASAUR.dexEntry.kind, "SEED", "Bulbasaur dex kind") +check(Data.text[Data.pokemon.BULBASAUR.dexEntry.text] ~= nil, "dex description text") +-- AI move-choice mods +eq(#Data.trainers.OPP_YOUNGSTER.aiMods, 0, "Youngster has no AI mods") +eq(table.concat(Data.trainers.OPP_POKEMANIAC.aiMods, ","), "1,2,3", "Pokemaniac AI mods") +-- fly warps +eq(Data.field.flyWarps.PALLET_TOWN.x, 5, "Pallet fly spot") +-- badge boost: BoulderBadge multiplies attack x9/8 (atk 9 -> 10) +local badged = battler(fixedMon("BULBASAUR", 5), bulba) +badged.badges = { BOULDERBADGE = true } +local dmgBadge = Damage.compute(ruleset, badged, defender, tackle, + { rng = function() return 255 end, forceCrit = false }) +eq(dmgBadge, 6, "BoulderBadge boosts physical damage (5 -> 6)") + +-- the full badge map (ApplyBadgeStatBoosts): Boulder -> Attack, +-- Thunder -> DEFENSE, Soul -> SPEED, Volcano -> Special. +-- Synthetic 10/10/10/10 battlers at L10: base damage +-- floor(floor(6*100*10/10)/50)+2 = 14 at max roll; a 9/8 boost moves +-- the attacker to 15 and the defender to 12. +do +local function plainBattler(badges) + return { curStats = { attack = 10, defense = 10, speed = 10, special = 10 }, + stages = {}, curTypes = {}, badges = badges, name = "TEST", + mon = { level = 10 }, def = { baseStats = { speed = 10 } } } +end +local physTest = { id = "PHYS_TEST", power = 100, type = "NORMAL", accuracy = 100 } +local specTest = { id = "SPEC_TEST", power = 100, type = "FIRE", accuracy = 100 } +local maxRoll = { rng = function() return 255 end, forceCrit = false } +eq((Damage.compute(ruleset, plainBattler(nil), plainBattler(nil), physTest, maxRoll)), + 14, "badge-free baseline damage") +eq((Damage.compute(ruleset, plainBattler({ BOULDERBADGE = true }), plainBattler(nil), + physTest, maxRoll)), + 15, "BOULDERBADGE boosts attack") +eq((Damage.compute(ruleset, plainBattler(nil), plainBattler({ THUNDERBADGE = true }), + physTest, maxRoll)), + 12, "THUNDERBADGE boosts defense") +eq((Damage.compute(ruleset, plainBattler(nil), plainBattler({ SOULBADGE = true }), + physTest, maxRoll)), + 14, "SOULBADGE does not boost defense") +eq((Damage.compute(ruleset, plainBattler({ VOLCANOBADGE = true }), plainBattler(nil), + specTest, maxRoll)), + 15, "VOLCANOBADGE boosts special (attacking)") +eq((Damage.compute(ruleset, plainBattler(nil), plainBattler({ VOLCANOBADGE = true }), + specTest, maxRoll)), + 12, "VOLCANOBADGE boosts special (defending)") +local TurnOrder = require("src.battle.TurnOrder") +eq(TurnOrder.effectiveSpeed(plainBattler({ SOULBADGE = true })), 11, + "SOULBADGE boosts speed") +eq(TurnOrder.effectiveSpeed(plainBattler({ THUNDERBADGE = true })), 10, + "THUNDERBADGE does not boost speed") + +-- confusion self-hit: typeless 40-power hit with no damage roll +-- (HandleSelfConfusionDamage skips RandomizeDamage) whose Reflect check +-- reads the OPPONENT's screens, not the user's own +local confused = plainBattler(nil) +local confMove = { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 } +local selfHitA = Damage.compute(ruleset, confused, confused, confMove, + { rng = function() return 255 end, forceCrit = false, typeless = true }) +local selfHitB = Damage.compute(ruleset, confused, confused, confMove, + { rng = function(a) return a end, forceCrit = false, typeless = true }) +eq(selfHitA, selfHitB, "confusion self-hit damage is deterministic") +confused.reflect = true +eq((Damage.compute(ruleset, confused, confused, confMove, + { rng = function() return 255 end, forceCrit = false, typeless = true })), + selfHitA, "own Reflect does not soften the self-hit") +local reflOpp = plainBattler(nil) +reflOpp.reflect = true +check(Damage.compute(ruleset, confused, confused, confMove, + { rng = function() return 255 end, forceCrit = false, + typeless = true, screens = reflOpp }) < selfHitA, + "the opponent's Reflect doubles the self-hit defense") + +-- MIST blocks primary stat drops but NOT side-effect drops +-- (StatModifierDownEffect's side-effect branch skips MoveHitTest) +local MoveEffects = require("src.battle.MoveEffects") +local sideRng = { rng = function() return 0 end } +local misted = { stages = {}, mist = true, name = "MISTY", mon = {} } +MoveEffects.secondary.ATTACK_DOWN_SIDE_EFFECT(sideRng, nil, misted) +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, nil, misted2) +check(misted2.stages.attack == nil + and mistMsgs[1]:find("MIST", 1, true) ~= nil, + "primary stat drop still blocked by MIST") + +-- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP +-- (substitute.asm only fails on subtraction underflow) +local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" } +MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) +check(subUser.substituteHP ~= nil and subUser.mon.hp == 0, + "substitute built at exactly 1/4 max HP leaves 0 HP") +local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } +local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) +check(subUser2.substituteHP == nil + and subMsgs[1]:find("weak", 1, true) ~= nil, + "substitute fails below 1/4 max HP") + +-- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of +-- a mon whose sleep/freeze it just cured (haze.asm selected move $ff) +local hazeUser = { stages = { attack = 2 }, xAccuracy = true, mon = {}, name = "HAZER" } +local hazeTarget = { stages = {}, disabledSlot = 1, disabledTurns = 3, + mon = { status = "FRZ" }, name = "FROZEN" } +MoveEffects.primary.HAZE_EFFECT(sideRng, hazeUser, hazeTarget) +check(hazeUser.xAccuracy == nil and hazeTarget.disabledSlot == nil, + "Haze clears X ACCURACY and Disable") +check(hazeTarget.mon.status == nil and hazeTarget.skipMove == true, + "Haze cures the target's freeze and forfeits its move") +local StatusMod = require("src.battle.Status") +local hazeCanMove, hazeMsgs = StatusMod.beforeMove(hazeTarget, sideRng.rng) +check(hazeCanMove == false and #hazeMsgs == 0 and hazeTarget.skipMove == nil, + "the forfeited move is skipped silently") +end +-- X item in a stub battle +local ItemFx = require("src.inventory.ItemEffects") +local stubBattle = { player = badged, kind = "wild" } +local xr = ItemFx.use(Data, Game and Game.save or require("src.core.SaveData").newGame(), + "X_ATTACK", nil, stubBattle) +eq(xr, "consumed", "X ATTACK usable in battle") +eq(badged.stages.attack, 1, "X ATTACK raises attack stage") +local xr2 = ItemFx.use(Data, require("src.core.SaveData").newGame(), "X_ATTACK", nil, nil) +eq(xr2, "failed", "X ATTACK unusable outside battle") + +-- ---------------------------------------------------------------- battle loop (scripted) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = require("src.core.SaveData").newGame() +table.insert(Game.save.party, Pokemon.new(Data, "BULBASAUR", 5)) + +local BattleState = require("src.battle.BattleState") +local finished = nil +local battle = BattleState.newWild(Game, "RATTATA", 2) +battle.onFinish = function(result) finished = result end +StateStack:push(battle) + +-- drive the battle: mash A and pick FIGHT/first move until it ends +local guard = 0 +while finished == nil and guard < 20000 do + guard = guard + 1 + Input:keypressed("z") -- A button + Input:step() + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input:keyreleased("z") +end +check(finished == "win" or finished == "lose", + "wild battle runs to completion (result: " .. tostring(finished) .. ")") +check(Game.save.party[1].exp > Growth.expForLevel("MEDIUM_SLOW", 5) or finished == "lose", + "winner gained experience") + +-- ---------------------------------------------------------------- script runner +local ScriptRunner = require("src.script.ScriptRunner") +local Flags = require("src.script.Flags") +local runner = ScriptRunner.new(Game, nil) +local ranBattle = false +runner.overworld = nil +local script = { + { "set_flag", "TEST_FLAG" }, + { "check_flag", "TEST_FLAG" }, + { "jump_if_false", 5 }, + { "give_item", "POTION", 2 }, + { "clear_flag", "TEST_FLAG" }, +} +runner:run(script, {}) +-- give_item now blocks on its received-item box (GiveItem prints and +-- waits, like the original); pump the stack until the script finishes +local scriptGuard = 0 +while runner:isRunning() and scriptGuard < 2000 do + scriptGuard = scriptGuard + 1 + Input:keypressed("z") + Input:step() + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input:keyreleased("z") +end +check(not Flags.get(Game.save, "TEST_FLAG"), "script flag set/clear") +eq(Game.save.inventory.POTION, 2, "script give_item") + +-- ---------------------------------------------------------------- parcel quest chain +local mapScripts = require("data.scripts.init") +local function runScript(script) + local r = ScriptRunner.new(Game, nil) + r:run(script, {}) + local guard = 0 + while r:isRunning() and guard < 2000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +Flags.set(Game.save, "EVENT_GOT_STARTER") +check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), + "mart clerk script completes") +eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") +check(Flags.get(Game.save, "EVENT_GOT_OAKS_PARCEL"), "parcel flag set") + +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), + "Oak delivery script completes") +eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered") +check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set") +check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set") + +-- captain gives HM01 exactly once +check(runScript(mapScripts.talkScript("SS_ANNE_CAPTAINS_ROOM", + "TEXT_SSANNECAPTAINSROOM_CAPTAIN")), + "captain script completes") +eq(Game.save.inventory.HM_CUT, 1, "captain gives HM01 Cut") +runScript(mapScripts.talkScript("SS_ANNE_CAPTAINS_ROOM", + "TEXT_SSANNECAPTAINSROOM_CAPTAIN")) +eq(Game.save.inventory.HM_CUT, 1, "HM01 only given once") + +-- ---------------------------------------------------------------- hidden items / spinners / slots data +local hi = Data.field.hiddenItems.VIRIDIAN_FOREST +check(hi and hi[1].item == "POTION" and hi[1].x == 1 and hi[1].y == 18, + "Viridian Forest hidden POTION at (1,18)") +check(Data.field.hiddenCoins.GAME_CORNER and #Data.field.hiddenCoins.GAME_CORNER >= 6, + "Game Corner hidden coins extracted") +local slotSeats = Data.field.slotMachines.GAME_CORNER +check(slotSeats and #slotSeats >= 30, "Game Corner slot machine seats extracted") +eq(#Data.field.slotWheels, 3, "three slot wheels") +eq(Data.field.slotWheels[1][1], "7", "wheel 1 starts with 7") +eq(#Data.field.slotWheels[1], 18, "wheel 1 has 18 symbols") +local vgSpin = Data.field.spinners.VIRIDIAN_GYM +check(vgSpin and #vgSpin >= 10, "Viridian Gym spinner tiles extracted") +local b2f = Data.field.spinners.ROCKET_HIDEOUT_B2F +local found49 +for _, sp in ipairs(b2f) do + if sp.x == 4 and sp.y == 9 then found49 = sp end +end +check(found49 and found49.moves[1].dir == "left" and found49.moves[1].count == 2, + "Rocket Hideout B2F (4,9) arrow slides left 2") + +-- ---------------------------------------------------------------- cries +local cries = Data.audio and Data.audio.cries or {} +local cryCount = 0 +for _ in pairs(cries) do cryCount = cryCount + 1 end +check(cryCount >= 150, "cries rendered for the full dex (" .. cryCount .. ")") +local cf = io.open("assets/generated/audio/cries/pikachu.wav", "rb") +check(cf ~= nil, "Pikachu cry WAV exists") +if cf then cf:close() end + +-- ---------------------------------------------------------------- slot machine paylines +local SlotMachine = require("src.ui.SlotMachine") +local w7 = { { "7", "7", "7" }, { "7", "7", "7" }, { "7", "7", "7" } } +local win = SlotMachine.evaluate(w7, { 1, 1, 1 }, 1) +check(win and win.payout == 300 and win.symbol == "7", "7-7-7 pays 300") +local wBar = { { "X", "BAR", "Y" }, { "A", "BAR", "B" }, { "C", "BAR", "D" } } +win = SlotMachine.evaluate(wBar, { 1, 1, 1 }, 1) +check(win and win.payout == 100, "BAR middle row pays 100") +-- top row only counts from bet 2 up +local wTop = { { "X", "Y", "CHERRY" }, { "A", "B", "CHERRY" }, { "C", "D", "CHERRY" } } +check(SlotMachine.evaluate(wTop, { 1, 1, 1 }, 1) == nil, "bet 1 ignores top row") +win = SlotMachine.evaluate(wTop, { 1, 1, 1 }, 2) +check(win and win.payout == 8, "bet 2 pays the CHERRY top row (8)") +-- diagonal only counts at bet 3 +local wDiag = { { "X", "Y", "FISH" }, { "A", "FISH", "B" }, { "FISH", "C", "D" } } +check(SlotMachine.evaluate(wDiag, { 1, 1, 1 }, 2) == nil, "bet 2 ignores diagonals") +win = SlotMachine.evaluate(wDiag, { 1, 1, 1 }, 3) +check(win and win.payout == 15, "bet 3 pays the FISH diagonal (15)") +-- matches are taken in pokered's line-check order, not by best payout +-- (SlotMachine_CheckForMatches: bet 2 checks the top row before the middle) +local wOrder = { { "X", "7", "FISH" }, { "Y", "7", "FISH" }, { "Z", "7", "FISH" } } +win = SlotMachine.evaluate(wOrder, { 1, 1, 1 }, 2) +check(win and win.payout == 15 and win.symbol == "FISH", + "first matching line wins (top row checked before middle)") + +-- wheel 1 stop rule (SlotMachine_StopWheel1Early): stop unless the centred +-- middle symbol is a cherry; the seven-and-bar branch is pokered's bug and +-- never stops early +local wSlip1 = { { "7", "CHERRY", "BAR", "MOUSE" }, {}, {} } +check(not SlotMachine.stopWheel1Early(wSlip1, 1, false), + "wheel 1 slips past a centred cherry") +check(SlotMachine.stopWheel1Early(wSlip1, 2, false), + "wheel 1 stops when the middle symbol is not a cherry") +check(not SlotMachine.stopWheel1Early(wSlip1, 2, true), + "seven-and-bar wheel 1 never stops early (pokered bug)") + +-- wheel 2 slip rule (SlotMachine_StopWheel2Early / +-- SlotMachine_FindWheel1Wheel2Matches) +local wSlip2 = { { "7", "BAR", "CHERRY", "MOUSE", "FISH" }, + { "MOUSE", "BIRD", "FISH", "BAR", "7" }, {} } +local matched, tile = SlotMachine.findWheel1Wheel2Matches(wSlip2, 1, 1) +check(not matched and tile == "MOUSE", + "no wheel-1/2 alignment reports wheel 2's bottom tile") +matched, tile = SlotMachine.findWheel1Wheel2Matches(wSlip2, 1, 3) +check(matched and tile == "BAR", "middle/middle BAR alignment found") +check(not SlotMachine.stopWheel2Early(wSlip2, 1, 1, false), + "wheel 2 slips while no match is lined up") +check(SlotMachine.stopWheel2Early(wSlip2, 1, 3, false), + "wheel 2 stops as soon as a match is lined up") +check(SlotMachine.stopWheel2Early(wSlip2, 1, 3, true), + "seven-and-bar wheel 2 stops on a lined-up BAR") +local wCher = { { "A", "CHERRY", "B" }, { "C", "CHERRY", "D" }, {} } +check(SlotMachine.stopWheel2Early(wCher, 1, 1, false), + "normal wheel 2 stops on a lined-up cherry") +check(not SlotMachine.stopWheel2Early(wCher, 1, 1, true), + "seven-and-bar wheel 2 slips past a lined-up cherry") +local wBot7 = { { "A", "B", "C" }, { "7", "D", "E" }, {} } +check(SlotMachine.stopWheel2Early(wBot7, 1, 1, true), + "seven-and-bar wheel 2 stops on a bottom 7 even with no match") + +-- wheel 3 bias (SlotMachine_CheckForMatches): matches the flags forbid +-- are rolled past; allowed ones are accepted +local action = SlotMachine.checkForMatch(w7, { 1, 1, 1 }, 1, false, false) +check(action == "roll", "flags clear: wheel 3 rolls past any match") +action = SlotMachine.checkForMatch(w7, { 1, 1, 1 }, 1, true, false) +check(action == "roll", "can-win mode still rolls past a 7/BAR match") +action = SlotMachine.checkForMatch(w7, { 1, 1, 1 }, 1, false, true) +check(action == "accept", "seven-and-bar mode accepts the 7 match") +action = SlotMachine.checkForMatch(wTop, { 1, 1, 1 }, 2, true, false) +check(action == "accept", "can-win mode accepts a cherry match") +check(SlotMachine.checkForMatch(wTop, { 1, 1, 1 }, 1, true, false) == "nomatch", + "no lined-up symbols reports nomatch") + +-- reroll counter (wSlotMachineRerollCounter): a winnable no-match spin +-- rolls wheel 3 toward a match, burning one charge per symbol +local smStub = setmetatable({ + game = { data = {}, save = { coins = 10 } }, + wheels = { { "A", "A", "A" }, { "B", "B", "B" }, { "C", "C", "C" } }, + bet = 1, canWin = true, sevenBar = false, + offset = { 1, 1, 1 }, stopping = 3, slip = { 0, 0 }, reroll = 4, +}, SlotMachine) +smStub:checkForMatches() +check(smStub.stage == "reroll" and smStub.reroll == 3, + "winnable no-match spin rerolls wheel 3 (one charge burned)") +smStub.reroll = 1 +smStub:checkForMatches() +check(smStub.stage == "message" and smStub.message == "Not this time!", + "exhausted reroll counter gives 'Not this time!'") + +-- ---------------------------------------------------------------- 12-box PC +local Boxes = require("src.pokemon.Boxes") +local bsave = { box = { { species = "PIDGEY" } } } +Boxes.ensure(bsave) +eq(#bsave.boxes[1], 1, "legacy single box migrates into box 1") +check(bsave.box == nil, "legacy box field removed") +eq(#bsave.boxes, 12, "12 boxes") +for _ = 1, Boxes.CAPACITY - 1 do + table.insert(bsave.boxes[1], { species = "RATTATA" }) +end +local usedBox = Boxes.deposit(bsave, { species = "SPEAROW" }) +eq(usedBox, 2, "full box overflows into the next box") +eq(#bsave.boxes[2], 1, "overflow mon landed in box 2") + +-- ---------------------------------------------------------------- Itemfinder +local ifr = ItemFx.use(Data, Game.save, "ITEMFINDER", nil, nil) +eq(ifr, "itemfinder", "ITEMFINDER asks the overworld for hidden items") + +-- ---------------------------------------------------------------- Safari game +check(mapScripts.get("SAFARI_ZONE_GATE") and mapScripts.get("SAFARI_ZONE_GATE").onStep, + "Safari gate script registered") +Game.save.safari = { balls = 30, steps = 502 } +local sb = BattleState.newWild(Game, "NIDORAN_M", 22) +sb:makeSafari(Game.save.safari) +eq(sb.safariCatchRate, Data.pokemon.NIDORAN_M.catchRate, + "safari catch rate starts at the species rate") +sb.rng = function(a, b) return b end -- deterministic max rolls +sb:safariAction("rock") +eq(sb.safariCatchRate, math.min(255, Data.pokemon.NIDORAN_M.catchRate * 2), + "ROCK doubles the catch rate") +eq(sb.escapeFactor, 5, "ROCK raises the escape factor") +eq(sb.baitFactor, 0, "ROCK zeroes the bait factor") +sb:safariAction("bait") +eq(sb.safariCatchRate, math.floor(math.min(255, Data.pokemon.NIDORAN_M.catchRate * 2) / 2), + "BAIT halves the catch rate") +eq(sb.baitFactor, 5, "BAIT raises the bait factor") +eq(sb.escapeFactor, 0, "BAIT zeroes the escape factor") + +-- a full safari encounter driven to completion (mashing A throws balls) +Game.save.safari = { balls = 30, steps = 502 } +local sfin = nil +local sb2 = BattleState.newWild(Game, "CATERPIE", 5) +sb2:makeSafari(Game.save.safari) +sb2.onFinish = function(r) sfin = r end +StateStack:push(sb2) +guard = 0 +while sfin == nil and guard < 20000 do + guard = guard + 1 + Input:keypressed("z") + Input:step() + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input:keyreleased("z") +end +check(sfin == "caught" or sfin == "run", + "safari battle runs to completion (result: " .. tostring(sfin) .. ")") +check(Game.save.safari.balls < 30, "safari balls consumed") +Game.save.safari = nil + +-- ---------------------------------------------------------------- new extracted systems +check(Data.field.cardKeyDoors and Data.field.cardKeyDoors.doorTiles[1] == 24, + "card key door tiles extracted ($18)") +eq(#Data.field.badgeGates.ROUTE_23.guards, 7, "seven Route 23 badge guards") +eq(Data.field.badgeGates.ROUTE_23.guards[1].badge, "EARTHBADGE", + "first Route 23 guard checks the EARTHBADGE") +-- Route22Gate_Script: every frame Y < 4 -> wLastMap = ROUTE_23, else +-- ROUTE_22, so the north LAST_MAP warps leave onto Route 23 +do + local OW = require("src.world.OverworldController") + eq(OW.route22GateOutdoor(0), "ROUTE_23", "Route22Gate Y=0 -> Route 23") + eq(OW.route22GateOutdoor(3), "ROUTE_23", "Route22Gate Y=3 -> Route 23") + eq(OW.route22GateOutdoor(4), "ROUTE_22", "Route22Gate Y=4 -> Route 22") + eq(OW.route22GateOutdoor(7), "ROUTE_22", "Route22Gate Y=7 -> Route 22") + local north = Data.maps.ROUTE_22_GATE.warps[3] + local m, x, y = Warp.destination(Data, north, + { id = OW.route22GateOutdoor(0), x = 0, y = 0 }) + eq(m, "ROUTE_23", "north gate LAST_MAP with Y rewrite lands on Route 23") + eq(x, 7, "north gate lands on Route 23 south warp x") + eq(y, 139, "north gate lands on Route 23 south warp y") + local south = Data.maps.ROUTE_22_GATE.warps[1] + m, x, y = Warp.destination(Data, south, + { id = OW.route22GateOutdoor(7), x = 0, y = 0 }) + eq(m, "ROUTE_22", "south gate LAST_MAP with Y rewrite lands on Route 22") + eq(x, 8, "south gate lands on Route 22 gate warp x") + eq(y, 5, "south gate lands on Route 22 gate warp y") +end +eq(Data.field.forcedMovement.slopeMaps[1], "ROUTE_17", "Cycling Road slope map") +check(Data.field.seafoam.SEAFOAM_ISLANDS_B3F.currents[1].moves[1] ~= nil, + "Seafoam B3F current movement extracted") +eq(Data.field.gameCornerPoster.closedBlock, 42, "poster wall block $2a") +eq(Data.field.presetNames.player[1], "RED", "preset player names") +eq(Data.field.darkMaps.maps[1], "ROCK_TUNNEL_1F", "Rock Tunnel is dark") +check(Data.field.hiddenExtras.trashCans.adjacent[0][1] == 1, + "trash can adjacency table extracted") +check(#Data.field.hiddenExtras.trashCans.cans == 15, "15 Vermilion trash cans") +local tf = io.open(Data.field.title.logo.path, "rb") +check(tf ~= nil, "title logo asset exists") +if tf then tf:close() end +check(Data.moves.POUND.anim and Data.moves.POUND.anim.sound == "Pound", + "POUND plays its own sound") +local animCount = 0 +for _, mv in pairs(Data.moves) do + if mv.anim and mv.anim.sound then animCount = animCount + 1 end +end +eq(animCount, 165, "every move has an animation sound") +check(Data.moves.EARTHQUAKE.anim.shake == true, "EARTHQUAKE shakes the screen") + +-- ---------------------------------------------------------------- catch wobbles +local Catching = require("src.battle.Catching") +local wobbleMon = { status = nil, stats = { hp = 100 }, hp = 100 } +local caught, shakes = Catching.attempt("POKE_BALL", wobbleMon, { catchRate = 3 }, + function(a, b) return b end) -- max rolls +check(caught == false and shakes == 0, + "hopeless throw misses with 0 wobbles (Mewtwo-style)") +caught = Catching.attempt("MASTER_BALL", wobbleMon, { catchRate = 3 }, + function(a, b) return b end) +check(caught == true, "MASTER BALL never fails") + +-- ---------------------------------------------------------------- battle mechanics parity +-- scripted-rng probes of the move pipeline (trapping counter, raw-damage +-- recoil/drain, the 1/256 status-move miss, EXP.ALL's second pass) +do + local Damage = require("src.battle.Damage") + local function mkseq(vals) -- scripted rng: pops vals, then max rolls + local i = 0 + return function(a, b) + i = i + 1 + return vals[i] ~= nil and vals[i] or b + end + end + local savedParty = Game.save.party + + -- #1: trapping moves total 2-5 attacks (counter 1-4 continuations) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local tb = BattleState.newWild(Game, "RATTATA", 5) + -- rng order: accuracy, crit, damage random, trapping counter + tb.rng = mkseq({ 0, 255, 255, 0 }) -- counter roll 0 -> 1 continuation + tb:performMove(tb.player, tb.enemy, { id = "WRAP", pp = 10 }) + eq(tb.player.trappingTurns, 1, + "trapping roll 0 gives 1 continuation (2 attacks total)") + local tb2 = BattleState.newWild(Game, "RATTATA", 5) + tb2.rng = mkseq({ 0, 255, 255, 7 }) -- counter roll 7 -> 4 continuations + tb2:performMove(tb2.player, tb2.enemy, { id = "WRAP", pp = 10 }) + eq(tb2.player.trappingTurns, 4, + "trapping roll 7 gives 4 continuations (5 attacks total)") + -- the victim stays held through the final hit; the bit clears only + -- at end of turn (CheckNumAttacksLeft) + tb:continueTrapping(tb.player, tb.enemy) + eq(tb.player.trappingTurns, 0, "final continuation leaves the counter at 0") + check(tb:lockedAction(tb.enemy) ~= nil + and tb:lockedAction(tb.enemy).special == "bound", + "victim is still held while the counter sits at 0") + tb:endOfTurn() + eq(tb.player.trappingTurns, nil, "end of turn releases the trap") + check(tb:lockedAction(tb.enemy) == nil, "victim is free after the release") + end + + -- #2: recoil and drain use the RAW computed damage, not the HP-capped + -- amount dealt + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local rb = BattleState.newWild(Game, "RATTATA", 3) + rb.enemy.mon.hp = 1 -- overkill target + local raw = Damage.compute(rb.ruleset, rb.player, rb.enemy, + Data.moves.TAKE_DOWN, { rng = mkseq({ 255, 255 }) }) + check(raw >= 8, "raw TAKE DOWN damage is meaningful (" .. raw .. ")") + rb.rng = mkseq({ 0, 255, 255 }) + local hpBefore = rb.player.mon.hp + rb:performMove(rb.player, rb.enemy, { id = "TAKE_DOWN", pp = 10 }) + eq(hpBefore - rb.player.mon.hp, math.floor(raw / 4), + "recoil is raw damage / 4 even when only 1 HP was dealt") + + local db = BattleState.newWild(Game, "RATTATA", 3) + db.enemy.mon.hp = 1 + db.player.mon.hp = 1 + local rawD = Damage.compute(db.ruleset, db.player, db.enemy, + Data.moves.MEGA_DRAIN, { rng = mkseq({ 255, 255 }) }) + check(rawD >= 4, "raw MEGA DRAIN damage is meaningful (" .. rawD .. ")") + db.rng = mkseq({ 0, 255, 255 }) + db:performMove(db.player, db.enemy, { id = "MEGA_DRAIN", pp = 10 }) + eq(db.player.mon.hp - 1, math.floor(rawD / 2), + "drain heals raw damage / 2 even when only 1 HP was dealt") + eq(db.lastDamage, math.floor(rawD / 2), + "drain halves wDamage in place (Counter would see the half)") + end + + -- #8: 100%-accuracy status moves still miss on the 255 roll + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local ab = BattleState.newWild(Game, "RATTATA", 5) + ab.rng = mkseq({ 255 }) -- the 1/256 miss + ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) + eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll") + ab.rng = mkseq({ 254 }) + ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) + eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll") + -- self-targeting status moves never roll accuracy at all + local sbst = BattleState.newWild(Game, "RATTATA", 5) + sbst.rng = function() error("self move must not roll accuracy") end + sbst:performMove(sbst.player, sbst.enemy, { id = "SHARPEN", pp = 10 }) + eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") + end + + -- #14: EXP.ALL second pass inherits the participant divisor and skips + -- fainted mons + do + local Experience = require("src.battle.Experience") + local mon1 = Pokemon.new(Data, "BULBASAUR", 30) + local mon2 = Pokemon.new(Data, "PIDGEY", 30) + mon2.hp = 0 + Game.save.party = { mon1, mon2 } + Game.save.inventory.EXP_ALL = 1 + local xb = BattleState.newWild(Game, "RATTATA", 10) + xb.participants = { [mon1] = true } + local exp1, exp2 = mon1.exp, mon2.exp + xb:enemyMonFainted() + local rat = Data.pokemon.RATTATA + eq(mon1.exp - exp1, + Experience.gainFor(rat, 10, false, 2, false) + + Experience.gainFor(rat, 10, false, 4, false), + "EXP.ALL: participant gets the half share plus the party share") + eq(mon2.exp - exp2, 0, "EXP.ALL second pass skips fainted mons") + Game.save.inventory.EXP_ALL = nil + end + + Game.save.party = savedParty +end + +-- ---------------------------------------------------------------- battle text/presentation parity +-- "Enemy " prefix, send-out variants, HP-bar drain, catch dex flow, +-- exact pokered strings (all verified against pret/pokered text files) +do + local savedParty = Game.save.party + local function mkseq(vals) + local i = 0 + return function(a, b) + i = i + 1 + return vals[i] ~= nil and vals[i] or b + end + end + local function hasText(b, s) + for _, it in ipairs(b.queue) do + if it.text and it.text:find(s, 1, true) then return true end + end + return false + end + local function hasDrain(b) + for _, it in ipairs(b.queue) do + if it.drain then return true end + end + return false + end + + -- the enemy-name prefix (/ macros print "Enemy ") + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local pb = BattleState.newWild(Game, "RATTATA", 5) + pb.rng = mkseq({ 0, 255, 255 }) + pb:performMove(pb.enemy, pb.player, { id = "TACKLE", pp = 10 }) + check(hasText(pb, "Enemy RATTATA\nused TACKLE!"), + "enemy move announcement carries the Enemy prefix") + pb.rng = mkseq({ 0, 255, 255 }) + pb:performMove(pb.player, pb.enemy, { id = "TACKLE", pp = 10 }) + check(hasText(pb, "BULBASAUR\nused TACKLE!") + and not hasText(pb, "Enemy BULBASAUR"), + "player move announcement has no prefix") + check(hasDrain(pb), "damage queues an HP-bar drain wait") + pb.enemy.mon.hp = 0 + pb:onFaint(pb.enemy) + check(hasText(pb, "Enemy RATTATA\nfainted!"), + "_EnemyMonFaintedText has the Enemy prefix") + + -- pre-built Status messages get the prefix spliced in + local MoveFx = require("src.battle.MoveEffects") + local parMsgs = MoveFx.primary.PARALYZE_EFFECT( + { rng = mkseq({}) }, pb.player, pb.enemy, Data.moves.THUNDER_WAVE) + eq(parMsgs[1], "Enemy RATTATA's\nparalyzed! It may\nnot attack!", + "_ParalyzedMayNotAttackText wording + prefix") + local failMsgs = MoveFx.primary.PARALYZE_EFFECT( + { rng = mkseq({}) }, pb.player, pb.enemy, Data.moves.THUNDER_WAVE) + eq(failMsgs[1], "But, it failed!", "_ButItFailedText has the comma") + + -- send-out shout buckets (PrintSendOutMonMessage thresholds) + pb.enemy.mon.stats = { hp = 20 } + pb.enemy.mon.hp = 20 + eq(pb:sendOutText("PIKA"), "Go! PIKA!", "send-out at full HP") + pb.enemy.mon.hp = 13 -- 65% + eq(pb:sendOutText("PIKA"), "Do it! PIKA!", "send-out at 40-69%") + pb.enemy.mon.hp = 3 -- 15% + eq(pb:sendOutText("PIKA"), "Get'm! PIKA!", "send-out at 10-39%") + pb.enemy.mon.hp = 1 -- 5% + eq(pb:sendOutText("PIKA"), "The enemy's weak!\nGet'm! PIKA!", + "send-out below 10%") + + -- HP-bar drain converges at UpdateHPBar's pixel pace (maxHP/96/frame) + local db = BattleState.newWild(Game, "RATTATA", 5) + local maxHP = db.enemy.mon.stats.hp + db.enemy.mon.hp = math.max(0, db.enemy.mon.hp - 5) + local frames = 0 + while db:stepHPDrain() and frames < 2000 do frames = frames + 1 end + eq(db.enemy.shownHP, db.enemy.mon.hp, "drain settles on the true HP") + local expect = math.ceil(5 / (maxHP / 96)) + check(math.abs(frames - expect) <= 1, + ("drain speed ~2 frames per bar pixel (%d ~ %d)"):format(frames, expect)) + + -- multi-hit count text: player vs enemy variants, always plural + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local mh = BattleState.newWild(Game, "SNORLAX", 30) + mh.rng = mkseq({ 7, 0, 255, 255 }) -- 5 hits, hit, no crit, max roll + mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 }) + check(hasText(mh, "Hit the enemy\n5 times!"), + "player multi-hit uses _MultiHitText") + Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) } + local mh2 = BattleState.newWild(Game, "RATTATA", 5) + mh2.rng = mkseq({ 7, 0, 255, 255 }) + mh2:performMove(mh2.enemy, mh2.player, { id = "DOUBLESLAP", pp = 10 }) + check(hasText(mh2, "Hit 5 times!"), + "enemy multi-hit uses _HitXTimesText (plural, no '(s)')") + + -- GainedText parity (experience.asm:342-354 + text_2.asm:1207-1226): + -- the amount from wExpAmountGained, "a boosted" for traded mons, + -- "with EXP.ALL," on the second pass -- and no invented summary + local Experience = require("src.battle.Experience") + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + local eb = BattleState.newWild(Game, "RATTATA", 10) + eb.participants = { [Game.save.party[1]] = true } + eb:enemyMonFainted() + local gain = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false) + check(hasText(eb, ("BULBASAUR gained\n%d EXP. Points!"):format(gain)), + "_GainedText + _ExpPointsText show the amount") + + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + Game.save.party[1].traded = true + local eb2 = BattleState.newWild(Game, "RATTATA", 10) + eb2.participants = { [Game.save.party[1]] = true } + eb2:enemyMonFainted() + local boosted = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, true) + check(hasText(eb2, ("BULBASAUR gained\na boosted\n%d EXP. Points!"):format(boosted)), + "_BoostedText tail for traded mons") + + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + Game.save.inventory.EXP_ALL = 1 + local eb3 = BattleState.newWild(Game, "RATTATA", 10) + eb3.participants = { [Game.save.party[1]] = true } + eb3:enemyMonFainted() + local share = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 2, false) + check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\n%d EXP. Points!"):format(share)), + "_WithExpAllText tail on the EXP.ALL pass") + check(not hasText(eb3, "divided"), "no invented EXP.ALL summary line") + Game.save.inventory.EXP_ALL = nil + + -- trainer next-mon send-out (EnemySendOutFirstMon, core.asm:1413-1435): + -- the announcement prints while the enemy pic + HUD are hidden, then + -- the pic grows out of the ball (AnimateSendingOutMon, core.asm:6801) + -- and the cry follows; no POOF on the enemy path + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + local nb = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + nb.enemy.mon.hp = 0 + nb:enemyMonFainted() + local pumps = 0 + while #nb.queue > 0 and not nb.enemySendingOut and pumps < 200 do + pumps = pumps + 1 + local item = table.remove(nb.queue, 1) + if item.fn then + nb.nextInsert = 0 -- updateQueue resets the insert cursor per fn + item.fn() + end + end + check(nb.enemySendingOut, "next enemy mon stays hidden while announced") + check(nb.enemy.mon.species == "EKANS", "the swap loaded the next party mon") + check(nb.queue[1] and nb.queue[1].text + and nb.queue[1].text:find("sent\nout EKANS!", 1, true), + "TrainerSentOutText queued before the reveal") + check(not (nb.queue[1] and nb.queue[1].anim) + and not (nb.queue[2] and nb.queue[2].anim), + "no POOF row on the enemy send-out path") + check(nb.queue[2] and nb.queue[2].fn, "the reveal act follows the text") + table.remove(nb.queue, 1) -- the sent-out text + local reveal = table.remove(nb.queue, 1) + nb.nextInsert = 0 + reveal.fn() + check(nb.enemySendingOut == false, "pic + HUD return after the text") + check(nb.growIn and nb.growIn.battler == nb.enemy, + "the reveal starts the AnimateSendingOutMon grow-in") + eq(nb:growInScale(nb.enemy), 0, "grow-in opens with the ball beat") + check(nb.queue[1] and nb.queue[1].wait == 12, "a queued hold covers the grow") + for _ = 1, 3 do nb:updateFx() end + eq(nb:growInScale(nb.enemy), 3 / 7, "3x3 stage after the ball beat") + for _ = 1, 4 do nb:updateFx() end + eq(nb:growInScale(nb.enemy), 5 / 7, "5x5 stage") + for _ = 1, 5 do nb:updateFx() end + check(nb.growIn == nil, "grow-in ends at full size after 12 frames") + table.remove(nb.queue, 1) -- the hold + local SoundMod = require("src.core.Sound") + local oldCry, criedSpecies = SoundMod.playCry, nil + SoundMod.playCry = function(_, species) criedSpecies = species end + local cryAct = table.remove(nb.queue, 1) + nb.nextInsert = 0 + cryAct.fn() + SoundMod.playCry = oldCry + eq(criedSpecies, "EKANS", "the new mon's cry plays after the grow") + + -- first-catch flow: new dex data text + registration; box transfer text + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + Game.save.pokedex.owned.EKANS = nil + local cb = BattleState.newWild(Game, "EKANS", 5) + cb:storeCaughtMon() + check(hasText(cb, "New POKéDEX data\nwill be added for\nEKANS!"), + "_ItemUseBallText06 on a first catch") + check(Game.save.pokedex.owned.EKANS == true, "species registered as owned") + eq(cb.result, "caught", "catch resolves the battle") + eq(#Game.save.party, 2, "caught mon joined the party") + + Game.save.party = {} + for _ = 1, 6 do table.insert(Game.save.party, Pokemon.new(Data, "RATTATA", 5)) end + local cb2 = BattleState.newWild(Game, "EKANS", 5) + cb2:storeCaughtMon() + check(hasText(cb2, "EKANS was\ntransferred to\nsomeone's PC!"), + "_ItemUseBallText08 before meeting Bill") + check(not hasText(cb2, "New POKéDEX data"), + "no dex page for an already-owned species") + Game.save.flags.EVENT_MET_BILL = true + local cb3 = BattleState.newWild(Game, "EKANS", 5) + cb3:storeCaughtMon() + check(hasText(cb3, "EKANS was\ntransferred to\nBILL's PC!"), + "_ItemUseBallText07 after meeting Bill") + Game.save.flags.EVENT_MET_BILL = nil + + -- trainer defeat wording (_TrainerDefeatedText) + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + local tb = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + tb.enemyIndex = #tb.enemyParty + tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def, + name = tb.enemy.name, isPlayer = false } + tb.enemy.mon.hp = 0 + tb:enemyMonFainted() + check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name, + tb.trainer.name)), + "trainer defeat uses ' defeated !'") + + -- PartyMenu onCancel fires when backing out without a pick + do + local PartyMenu = require("src.ui.PartyMenu") + local cancelled = false + local pm = PartyMenu.new(Game, { pickOnly = true, + onCancel = function() cancelled = true end }) + StateStack:push(pm) + Input.pressed = { b = true } + StateStack:update(1 / 60) + Input.pressed = {} + check(cancelled, "PartyMenu onCancel fires on B") + end + + Game.save.party = savedParty +end + +-- ---------------------------------------------------------------- trainer class AI +local aiClasses = require("data.scripts.ai_classes") +check(aiClasses.OPP_BROCK.onStatus and aiClasses.OPP_BROCK.item == "FULL_HEAL", + "Brock full-heals status") +local TrainerAI = require("src.battle.TrainerAI") +local stubEnemy = { mon = { status = "SLP", hp = 50, stats = { hp = 50 } }, + stages = {}, name = "ONIX" } +local stubBattleAI = { kind = "trainer", trainer = { id = "OPP_BROCK", name = "BROCK" }, + enemy = stubEnemy, aiUses = 5, rng = function() return 0 end, + data = Data } +local act = TrainerAI.classAction(stubBattleAI) +check(act and act.special == "aiItem" and act.item == "FULL_HEAL", + "Brock's AI reaches for a FULL HEAL") +local msgs = TrainerAI.useItem(stubBattleAI, "FULL_HEAL") +check(stubEnemy.mon.status == nil and #msgs >= 1, "AI FULL HEAL cures the status") + +-- AI layer 1: zero-power status-ailment moves vs an already-statused +-- player are heavily discouraged (pokered adds +5 to the score); the +-- faithful min-score pick then never selects them over a better move +-- (trainer_ai.asm min-score selection, not the old weighted-random) +do +local aiMon1 = { curMoves = { { id = "TOXIC", pp = 10 }, { id = "TACKLE", pp = 10 } } } +local aiBattle1 = { enemyAIMods = { 1 }, data = Data, + player = { mon = { status = "PAR" }, curTypes = { "NORMAL" } } } +-- scores land at {15, 10}: TACKLE is the sole minimum, chosen for any roll +eq(TrainerAI.chooseMove(aiMon1, function(a, b) return a end, aiBattle1).id, + "TACKLE", "discouraged status move is never chosen over a better move") +eq(TrainerAI.chooseMove(aiMon1, function(a, b) return b end, aiBattle1).id, + "TACKLE", "status move heavily discouraged when the player is statused") + +-- AI layer 2: stat-modifying effects encouraged only on the SECOND +-- selection per enemy mon (wAILayer2Encouragement == 1) +local aiMon2 = { curMoves = { { id = "GROWL", pp = 10 }, { id = "TACKLE", pp = 10 } } } +local aiBattle2 = { enemyAIMods = { 2 }, data = Data, + player = { mon = {}, curTypes = { "NORMAL" } } } +local pick11 = function(a, b) return math.min(b, 11) end +eq(TrainerAI.chooseMove(aiMon2, pick11, aiBattle2).id, "TACKLE", + "no layer-2 encouragement on the first selection") +eq(TrainerAI.chooseMove(aiMon2, pick11, aiBattle2).id, "GROWL", + "stat moves encouraged on the second selection") +eq(TrainerAI.chooseMove(aiMon2, pick11, aiBattle2).id, "TACKLE", + "the encouragement expires after the second selection") + +-- AI layer 3: single-row type lookup covers non-damaging moves too +-- (THUNDER_WAVE vs a Water-type reads the ELECTRIC->WATER row) +local aiMon3 = { curMoves = { { id = "THUNDER_WAVE", pp = 10 }, + { id = "TACKLE", pp = 10 } } } +local aiBattle3 = { enemyAIMods = { 3 }, data = Data, + player = { mon = {}, curTypes = { "WATER" } } } +eq(TrainerAI.chooseMove(aiMon3, function(a, b) return math.min(b, 30) end, aiBattle3).id, + "THUNDER_WAVE", "layer 3 encourages a super-effective status move") +end + +-- ---------------------------------------------------------------- exp split / traded boost +local Experience = require("src.battle.Experience") +local rattataDef = Data.pokemon.RATTATA +local soloExp = Experience.gainFor(rattataDef, 10, false, 1, false) +local splitExp = Experience.gainFor(rattataDef, 10, false, 2, false) +local tradedExp = Experience.gainFor(rattataDef, 10, false, 1, true) +eq(splitExp, math.floor(soloExp / 2), "exp splits between two participants") +eq(tradedExp, math.floor(soloExp * 3 / 2), "traded mons earn x1.5 exp") + +-- ---------------------------------------------------------------- lockstep tie inversion +local TurnOrder = require("src.battle.TurnOrder") +local fast = { curStats = { speed = 50 }, stages = {}, mon = { status = nil } } +local fast2 = { curStats = { speed = 50 }, stages = {}, mon = { status = nil } } +local tieRng = function() return 0 end -- always "a first" +check(TurnOrder.firstMover(fast, nil, fast2, nil, tieRng) == true, + "speed tie: roll 0 means a moves first") +check(TurnOrder.firstMover(fast, nil, fast2, nil, tieRng, true) == false, + "the link guest inverts the shared tie roll") + +-- ---------------------------------------------------------------- naming screen +local NamingScreen = require("src.ui.NamingScreen") +local named = nil +local ns = NamingScreen.new(Game, { title = "TEST?", maxLen = 7, presets = { "RED" }, + onDone = function(n) named = n end }) +StateStack:push(ns) +guard = 0 +while named == nil and guard < 2000 do + guard = guard + 1 + Input:keypressed("z") + Input:step() + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input:keyreleased("z") +end +check(named ~= nil and #named > 0, "naming screen produces a name (" .. tostring(named) .. ")") + +-- ---------------------------------------------------------------- town map / credits / emotes / slots art +local tmap = Data.field.townMap +check(tmap and tmap.locations.PALLET_TOWN.x == 2 and tmap.locations.PALLET_TOWN.y == 11, + "Pallet Town at (2,11) on the town map") +check(tmap.locations.SILPH_CO_11F ~= nil, "indoor maps resolve to town map entries") +eq(#Data.field.credits.screens, 35, "35 credit screens extracted") +eq(Data.field.credits.screens[2].lines[1].text, "DIRECTOR", "credits screen 2 is DIRECTOR") +eq(Data.field.credits.screens[2].lines[2].text, "SATOSHI TAJIRI", "credited to Satoshi Tajiri") +check(#Data.field.credits.mons == 15, "15 credits mons") +local eb = Data.field.emotionBubbles +check(eb and eb.bubbles[1].name == "EXCLAMATION_BUBBLE", "exclamation bubble crop first") +local ef = io.open(eb.path, "rb") +check(ef ~= nil, "emotes sheet exists") if ef then ef:close() end +local ss = Data.field.slotSymbols +check(ss and ss.symbols["7"] and ss.symbols.BAR and ss.symbols.CHERRY, + "slot symbol crops extracted") +local sf = io.open(ss.sheet, "rb") +check(sf ~= nil, "slot symbols sheet exists") if sf then sf:close() end +eq(Data.field.oldManBattle.species, "WEEDLE", "old man demos a Weedle") +eq(Data.field.oldManBattle.level, 5, "at level 5") +eq(Data.field.pcItemCap, 50, "PC item capacity is 50") +eq(Data.field.coinPurchases[1].coins, 50, "the clerk sells 50 coins") +eq(#Data.field.coinPurchases, 1, "and only 50 (no 500-coin purchase exists)") + +-- ---------------------------------------------------------------- battle animations +check(Data.battle_anims ~= nil, "battle_anims data loads") +local animMoves = 0 +for _ in pairs(Data.battle_anims.moveAnims) do animMoves = animMoves + 1 end +eq(animMoves, 202, "all 165 moves + 37 misc anims have sequences") +check(Data.battle_anims.moveAnims.POOF_ANIM ~= nil, "send-out POOF anim extracted") +check(Data.battle_anims.moveAnims.TOSS_ANIM ~= nil, "ball TOSS anim extracted") +local AnimPlayer = require("src.battle.AnimPlayer") +local ap = AnimPlayer.new(Data.battle_anims) +ap:start("POUND", true) +local frames = 0 +while not ap:isDone() and frames < 300 do + frames = frames + 1 + ap:update() +end +check(ap:isDone() and frames > 4, "POUND's animation plays (" .. frames .. " frames)") +ap:start("THUNDERBOLT", false) +frames = 0 +while not ap:isDone() and frames < 600 do + frames = frames + 1 + ap:update() +end +check(ap:isDone(), "THUNDERBOLT plays mirrored for the enemy") + +-- ---------------------------------------------------------------- tile-pair collisions +check(Data.field.tilePairs and #Data.field.tilePairs.land > 0, + "tile-pair (elevation) collisions extracted") +local hasForestPair = false +for _, p in ipairs(Data.field.tilePairs.land) do + if p.tileset == "FOREST" and p.a == 0x30 and p.b == 0x2E then hasForestPair = true end +end +check(hasForestPair, "Viridian Forest ledge pair $30/$2E present") +local Collision = require("src.world.Collision") +Collision.load(Data) +-- a fake forest map: standing on $30, moving onto $2E must be blocked +local fakeForest = { + def = { tileset = "FOREST" }, + inBounds = function() return true end, + isWalkableCell = function() return true end, + isWaterCell = function() return false end, + cellTile = function(_, cx, cy) return cy == 0 and 0x30 or 0x2E end, +} +local mover = { cellX = 0, cellY = 0, surfing = false } +local ok2 = Collision.canMove(fakeForest, { mover }, mover, "down") +check(ok2 == false, "tile-pair blocks crossing a forest elevation edge") + +-- ---------------------------------------------------------------- START menu gating +local StartMenu = require("src.ui.StartMenu") +local blankSave = require("src.core.SaveData").newGame() +local gs = { data = Data, save = blankSave, overworld = nil } +local menu = StartMenu.new(gs) +local labels = {} +for _, it in ipairs(menu.items) do labels[it.label] = true end +check(not labels["POKéDEX"], "POKéDEX hidden before the dex is earned") +check(labels["POKéMON"], "POKéMON always listed (draw_start_menu.asm; empty party no-ops)") +check(labels["ITEM"] and labels["SAVE"], "ITEM and SAVE always present") +blankSave.flags.EVENT_GOT_POKEDEX = true +table.insert(blankSave.party, Pokemon.new(Data, "PIKACHU", 5)) +local menu2 = StartMenu.new(gs) +local labels2 = {} +for _, it in ipairs(menu2.items) do labels2[it.label] = true end +check(labels2["POKéDEX"] and labels2["POKéMON"], + "POKéDEX and POKéMON appear once earned") + +-- ---------------------------------------------------------------- old man catch demo +local demoBattle = BattleState.newWild(Game, "WEEDLE", 5) +demoBattle:makeOldManDemo() +local demoDone = nil +demoBattle.onFinish = function(r) demoDone = r end +local partyBefore = #Game.save.party +StateStack:push(demoBattle) +guard = 0 +while demoDone == nil and guard < 5000 do + guard = guard + 1 + Input:keypressed("z") + Input:step() + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input:keyreleased("z") +end +check(demoDone ~= nil, "old man catch demo runs to completion") +eq(#Game.save.party, partyBefore, "the demo Weedle is not kept") + +-- save round trip +local SaveData = require("src.core.SaveData") +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") + +-- ---------------------------------------------------------------- save/load deep round trip +-- A representative save table survives SaveData.save -> load exactly +-- (the love stub keeps the file in memory, so no temp path is needed). +do + local SD = require("src.core.SaveData") + local rep = SD.newGame() + rep.player.id = 54321 + rep.player.map = "CERULEAN_CITY" + rep.player.x, rep.player.y, rep.player.facing = 10, 12, "left" + local mon = Pokemon.new(Data, "PIKACHU", 25) + mon.dvs = { attack = 10, defense = 5, speed = 15, special = 0, hp = 4 } + mon.statExp = { hp = 1234, attack = 999, defense = 0, speed = 65535, special = 7 } + mon.status = "PAR" + mon.moves[1].pp = 3 + mon.moves[1].ppUps = 2 + mon.otId = 12345 + mon.nickname = "SPARKY" + table.insert(rep.party, mon) + rep.boxes = {} + for i = 1, 12 do rep.boxes[i] = {} end + table.insert(rep.boxes[3], Pokemon.new(Data, "CATERPIE", 4)) + rep.currentBox = 3 + rep.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_OAKS_PARCEL = true } + rep.inventory = { POTION = 3, POKE_BALL = 10, TOWN_MAP = 1 } + rep.bagOrder = { "POKE_BALL", "POTION", "TOWN_MAP" } + rep.pcItems = { ANTIDOTE = 2 } + rep.coins = 777 + rep.money = 2469 + rep.playTime = 123.5 + -- Options persist in options.lua (separate from the game save). A full + -- set of keys is used so mergeOptions doesn't invent extras that would + -- trip deepEq if we compared the live tables naively. + rep.options = { + textSpeed = 3, animations = false, battleStyle = "SET", + ruleset = "gen1_faithful", musicVol = 4, sfxVol = 2, musicFilter = 2, + colors = "og", tilt = 2, gbcfx = 3, + } + rep.defeatedTrainers = { ["OPP_BROCK:1"] = true } + rep.pokedex = { seen = { PIKACHU = true, CATERPIE = true }, + owned = { PIKACHU = true } } + + local function deepEq(a, b, path) + if type(a) ~= type(b) then return false, path end + if type(a) ~= "table" then + if a ~= b then return false, path end + return true + end + for k, v in pairs(a) do + local ok, p = deepEq(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, p end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true + end + check(SD.save(rep), "representative save writes") + local back = SD.load() + -- Progress is in save.lua; options come back from options.lua. + eq(back.options.musicVol, 4, "options.lua round-trips musicVol") + eq(back.options.sfxVol, 2, "options.lua round-trips sfxVol") + eq(back.options.animations, false, "options.lua round-trips animations") + eq(back.options.colors, "og", "options.lua round-trips colors") + eq(back.options.tilt, 2, "options.lua round-trips tilt") + eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") + local origOpts, loadedOpts = rep.options, back.options + rep.options, back.options = nil, nil + local same, where = deepEq(rep, back, "save") + check(same, "save/load deep round trip" .. (same and "" or (" (differs at " .. where .. ")"))) + rep.options, back.options = origOpts, loadedOpts + -- leave defaults for later tests that expect a clean options.lua + SD.saveOptions(SD.defaultOptions()) +end + +-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest) +-- The threshold byte b from engine/battle/core.asm's shift chain: +-- srl (speed/2), then sla (cap 255) without Focus Energy or srl with the +-- FE bug, then sla+sla (cap) for high-crit moves or srl for normal ones; +-- crit when rand(0..255) < b. +do + local function critThreshold(speed, moveId, focusEnergy, rs) + local a = { def = { baseStats = { speed = speed } }, focusEnergy = focusEnergy } + local n = 0 + for i = 0, 255 do + if Damage.critRoll(rs or ruleset, a, moveId, function() return i end) then + n = n + 1 + end + end + return n + end + eq(critThreshold(128, "TACKLE", false), 64, "crit: speed 128 normal move -> 64/256") + eq(critThreshold(90, "TACKLE", false), 45, "crit: srl/sla/srl floors (speed 90 -> 45)") + eq(critThreshold(128, "SLASH", false), 255, "crit: speed 128 high-crit capped at 255/256") + eq(critThreshold(115, "SLASH", false), 255, "crit: Persian-speed Slash also caps at 255") + eq(critThreshold(60, "SLASH", false), 240, "crit: speed 60 high-crit -> 240/256 (uncapped x4)") + eq(critThreshold(128, "TACKLE", true), 16, "crit: Focus Energy bug quarters (128 -> 16/256)") + eq(critThreshold(128, "SLASH", true), 128, "crit: FE bug + high-crit (srl then sla sla -> 128)") + local rsFixed = { focusEnergyBug = false } + eq(critThreshold(32, "TACKLE", true, rsFixed), 64, + "crit: FE without the bug quadruples (32 -> 64/256 vs 16)") +end + +-- ---------------------------------------------------------------- catch RNG order (ItemUseBall) +-- pokered rolls Rand1 (0..ballMax), subtracts the status bonus (underflow +-- = instant catch), compares against the catch rate (failure never rolls +-- again), then rolls Rand2 (0..255) against the HP factor X. +do + local Catching2 = require("src.battle.Catching") + local function seq(vals) + local calls, i = {}, 0 + return function(a, b) + i = i + 1 + table.insert(calls, { a, b }) + return assert(vals[i], "rng over-consumed") + end, calls + end + -- full-HP 100-max mon, POKe BALL: X = floor(floor(100*255/12)/25) = 85 + local mon = { status = nil, stats = { hp = 100 }, hp = 100 } + + -- MASTER BALL rolls nothing + local rng, calls = seq({}) + local caught, shakes = Catching2.attempt("MASTER_BALL", mon, { catchRate = 3 }, rng) + check(caught == true and #calls == 0, "MASTER BALL consumes no rolls") + + -- Rand1 > rate fails without a second roll; z = floor(85*39/255) = 13 -> 1 shake + rng, calls = seq({ 150 }) + caught, shakes = Catching2.attempt("POKE_BALL", mon, { catchRate = 100 }, rng) + check(caught == false, "Rand1 above catch rate fails") + eq(#calls, 1, "rate-compare failure consumes exactly one roll") + eq(calls[1][2], 255, "Rand1 range is 0..255 for a POKe BALL") + eq(shakes, 1, "z=13 wobble tier -> 1 shake") + + -- Rand1 == rate proceeds; Rand2 == X catches (<= compare) + rng, calls = seq({ 100, 85 }) + caught = Catching2.attempt("POKE_BALL", mon, { catchRate = 100 }, rng) + check(caught == true, "Rand1 == rate proceeds and Rand2 == X catches") + eq(#calls, 2, "successful catch consumed Rand1 then Rand2") + eq(calls[2][1], 0, "Rand2 lower bound is 0") + eq(calls[2][2], 255, "Rand2 range is 0..255 regardless of ball") + + -- Rand2 = X+1 fails on the wobble roll with the same shake tiers + rng, calls = seq({ 100, 86 }) + caught, shakes = Catching2.attempt("POKE_BALL", mon, { catchRate = 100 }, rng) + check(caught == false and #calls == 2, "Rand2 above X fails after two rolls") + eq(shakes, 1, "second-path failure shares the shake tiers") + + -- status subtraction underflow: sleep bonus 25 auto-catches on Rand1 < 25 + local slp = { status = "SLP", stats = { hp = 100 }, hp = 100 } + rng, calls = seq({ 24 }) + caught, shakes = Catching2.attempt("POKE_BALL", slp, { catchRate = 0 }, rng) + check(caught == true and #calls == 1, "sleep underflow catches on Rand1 alone") + eq(shakes, 3, "underflow catch reports the full 3 shakes") + + -- rate >= ball max: the rate compare can never fail (GREAT BALL 0..200) + rng, calls = seq({ 200, 255 }) + caught, shakes = Catching2.attempt("GREAT_BALL", mon, { catchRate = 255 }, rng) + eq(calls[1][2], 200, "GREAT BALL Rand1 range is 0..200") + check(caught == false and #calls == 2, + "rate above ball max always reaches Rand2 (255 > X=127 fails)") + eq(shakes, 2, "GREAT BALL fail: z = floor(127*127/255) = 63 -> 2 shakes") + + -- 3-shake tier: rate 200, low HP (X=255): z = floor(255*78/255) = 78 + local weak = { status = nil, stats = { hp = 100 }, hp = 4 } + rng, calls = seq({ 255 }) + caught, shakes = Catching2.attempt("POKE_BALL", weak, { catchRate = 200 }, rng) + check(caught == false, "Rand1 255 > rate 200 fails") + eq(shakes, 3, "z=78 wobble tier -> 3 shakes") +end + +-- ---------------------------------------------------------------- survey zoom +do + local Zoom = require("src.render.Zoom") + local S = 6 + eq(Zoom.scale(S), 6, "default zoom = fit scale") + Zoom.step(-1, S) + eq(Zoom.scale(S), 5, "wheel down steps out one level") + for _ = 1, 20 do Zoom.step(-1, S) end + eq(Zoom.scale(S), 1, "zoom out clamps at 1") + for _ = 1, 40 do Zoom.step(1, S) end + eq(Zoom.scale(S), 12, "zoom in clamps at 2*S") + Zoom.reset() + eq(Zoom.scale(S), 6, "reset restores default") + + -- offset-from-S: a window resize keeps the relative zoom + Zoom.step(-2, S) + eq(Zoom.scale(4), 2, "offset survives fit-scale change") + + -- world view size in world pixels + local vw, vh = Zoom.viewSize(6, 160, 144) -- s' = 4 + eq(vw, 240, "view width at s'=4 of S=6") + eq(vh, 216, "view height at s'=4 of S=6") + Zoom.reset() + vw, vh = Zoom.viewSize(6, 160, 144) + eq(vw, 160, "default view width is 160") + eq(vh, 144, "default view height is 144") + + -- window-filling world view (phone letterbox voids → more map) + local fw, fh = Zoom.fillViewSize(2, 390, 844) + eq(fw, 195, "fill view width at s'=2") + eq(fh, 422, "fill view height at s'=2") + + -- input gate: only free-roaming overworld accepts zoom input + local ow = { runner = { isRunning = function() return false end } } + check(Zoom.gateOK(ow, ow), "gate open when overworld topmost") + check(not Zoom.gateOK({}, ow), "gate closed when a menu is on top") + check(not Zoom.gateOK(nil, ow), "gate closed with empty stack") + ow.transitioning = true + check(not Zoom.gateOK(ow, ow), "gate closed while transitioning") + ow.transitioning = false + ow.runner = { isRunning = function() return true end } + check(not Zoom.gateOK(ow, ow), "gate closed while a script runs") + Zoom.reset() +end + +-- ---------------------------------------------------------------- zoom camera +do + local Camera = require("src.render.Camera") + local cam = Camera.new() + cam:follow(160, 160) + eq(cam.x, 96, "legacy follow x = px - 64") + eq(cam.y, 96, "legacy follow y = py - 64") + cam:follow(160, 160, 320, 288) + eq(cam.x, 160 - (320 / 2 - 16), "wide view keeps player centered x") + eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y") +end + +-- ---------------------------------------------------------------- spawn filter +do + local OW = require("src.world.OverworldController") + local save = { defeatedTrainers = {} } + check(OW.objectVisible(save, "ROUTE_1", { index = 1 }), + "plain NPC visible") + check(not OW.objectVisible(save, "ROUTE_1", { index = 1, hidden = true }), + "hidden object invisible") + save.objectToggles = { ROUTE_1 = { GUARD = true } } + check(OW.objectVisible(save, "ROUTE_1", + { index = 1, hidden = true, name = "GUARD" }), + "show_object toggle overrides hidden") + save.itemsTaken = { ROUTE_1_obj_2 = true } + check(not OW.objectVisible(save, "ROUTE_1", { index = 2, item = "POTION" }), + "collected item ball invisible") + save.defeatedTrainers = { ROUTE_1_obj_3 = true } + check(not OW.objectVisible(save, "ROUTE_1", + { index = 3, pokemon = "PIDGEY" }), + "beaten static encounter gone") +end + +-- ---------------------------------------------------------------- battle fx ordering +-- The hit blink and the faint fx must ride the message queue behind the +-- move-animation row (pokered: anim -> blink -> bar drain -> texts -> +-- faint slide -> faint text), never fire live at damage time. +do + Game.save.party[1].hp = Game.save.party[1].stats.hp + local function animPending(b) + for _, r in ipairs(b.queue) do if r.anim then return true end end + return false + end + + -- hit blink + local ob = BattleState.newWild(Game, "RATTATA", 2) + ob.onFinish = function() end + ob.rng = function(a, b) return a end + ob.queue, ob.fx = {}, nil + ob:performMove(ob.player, ob.enemy, { id = "TACKLE", pp = 10 }) + check(ob.enemy.mon.hp < ob.enemy.mon.stats.hp, "tackle dealt damage") + check(not (ob.fx and ob.fx.blink), "hit blink is queued, not live") + local sawBlink, steps = false, 0 + while steps < 2000 and not sawBlink do + steps = steps + 1 + Input.pressed = { a = true } + if not ob:updateQueue() then break end + if ob.fx and ob.fx.blink then sawBlink = true end + end + check(sawBlink, "hit blink fires during queue playback") + check(sawBlink and not animPending(ob), "blink waits for the anim row") + + -- faint fx + Game.save.party[1].hp = Game.save.party[1].stats.hp + local fb = BattleState.newWild(Game, "RATTATA", 2) + fb.onFinish = function() end + fb.rng = function(a, b) return a end + fb.queue, fb.fx = {}, nil + fb.enemy.mon.hp = 1 + fb:performMove(fb.player, fb.enemy, { id = "TACKLE", pp = 10 }) + eq(fb.enemy.mon.hp, 0, "lethal tackle empties HP") + check(not fb.enemy.fainted and not (fb.fx and fb.fx.faint), + "faint fx is queued, not live") + local sawFaint + steps = 0 + while steps < 2000 and not sawFaint do + steps = steps + 1 + Input.pressed = { a = true } + if not fb:updateQueue() then break end + if fb.fx and fb.fx.faint then sawFaint = true end + end + check(sawFaint, "faint fx fires during queue playback") + check(sawFaint and fb.enemy.fainted, "fainted flag set with the slide") + check(sawFaint and not animPending(fb), "faint waits for the anim row") +end + +-- ---------------------------------------------------------------- ball toss animation chain +-- ItemUseBall packs the outcome into wPokeBallAnimData and TossBallAnimation +-- (engine/battle/animations.asm:2582) chains toss -> POOF -> HIDEPIC -> +-- SHAKE xN (-> POOF -> SHOWPIC on breakout); DoBallShakeSpecialEffects +-- plays a tink + 40-frame pause per shake, rewinding the same subanim. +do + local AnimPlayer = require("src.battle.AnimPlayer") + local ap = AnimPlayer.new(require("data.generated.battle_anims")) + ap:start("SHAKE_ANIM", true, { shakes = 3 }) + local tinks = 0 + for _, e in ipairs(ap.events) do + if e.effect == "SFX_TINK" then tinks = tinks + 1 end + end + eq(tinks, 3, "SHAKE_ANIM x3 fires three tink events") + local total = 0 + for _, s in ipairs(ap.steps) do total = total + s.dur end + check(total >= 3 * 40 + 3 * 16, + "three shakes include the 40-frame suspense pauses") + ap:start("SHAKE_ANIM", true, { shakes = 1 }) + local total1 = 0 + for _, s in ipairs(ap.steps) do total1 = total1 + s.dur end + check(total1 < total, "one shake is shorter than three") + + -- record the anim rows a ball throw queues, pumping the queue dry + local function chainOf(b, ball) + local seq = {} + local orig = b.animNext + b.animNext = function(s, name, isPlayer, shakes) + seq[#seq + 1] = shakes and (name .. "x" .. shakes) or name + return orig(s, name, isPlayer, shakes) + end + -- isolate the chain from the rest of the turn + b.executeAction = function() end + b.endOfTurn = function() end + b.storeCaughtMon = function() end + b.queue = {} + b:throwBall(ball) + local steps = 0 + while steps < 2000 do + steps = steps + 1 + Input.pressed = { a = true } + if not b:updateQueue() then break end + end + Input.pressed = {} + return table.concat(seq, ",") + end + + -- guaranteed capture (rng low): $43 anim data -> toss, poof, hide, 3 shakes + Game.save.party[1].hp = Game.save.party[1].stats.hp + local cb = BattleState.newWild(Game, "RATTATA", 3) + cb.onFinish = function() end + cb.rng = function(a, b) return a end + eq(chainOf(cb, "POKE_BALL"), + "TOSS_ANIM,POOF_ANIM,HIDEPIC_ANIM,SHAKE_ANIMx3", + "capture chain matches $43 anim data") + check(not (cb.fx and cb.fx.wobble), "no legacy wobble fx on capture") + check(cb.enemyHidden == true, "enemy pic hidden once the ball closes") + + -- breakout (rng high vs RATTATA => 2 shakes): the full 6-anim chain + local bb = BattleState.newWild(Game, "RATTATA", 3) + bb.onFinish = function() end + bb.rng = function(a, b) return b end + eq(chainOf(bb, "POKE_BALL"), + "TOSS_ANIM,POOF_ANIM,HIDEPIC_ANIM,SHAKE_ANIMx2,POOF_ANIM,SHOWPIC_ANIM", + "breakout chain matches $62 anim data") + check(not bb.enemyHidden, "enemy pic restored after the breakout") + + -- clean miss (rng high vs SNORLAX => 0 shakes): toss + poof only + local mb = BattleState.newWild(Game, "SNORLAX", 30) + mb.onFinish = function() end + mb.rng = function(a, b) return b end + eq(chainOf(mb, "POKE_BALL"), + "TOSS_ANIM,POOF_ANIM", + "clean miss stops after the poof ($20 anim data)") + check(not mb.enemyHidden, "missed mon never hides") +end + +-- ---------------------------------------------------------------- heal machine cadence +-- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball +-- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the +-- healed jingle while the machine flashes 8 times (10 frames a toggle), +-- then a 32-frame beat after the jingle ends. +do + local OW = require("src.world.OverworldController") + local ha = { balls = 3, lit = 0, timer = 0, visible = true } + local events, toggles = {}, 0 + local ok = pcall(function() + for frame = 1, 400 do + local wasVisible = ha.visible + local ev = OW.stepHealAnim(ha) + if ha.visible ~= wasVisible then toggles = toggles + 1 end + if ev then events[#events + 1] = frame .. ev end + if ev == "jingle" then ha.jingleDone = true end -- headless: no audio + if ev == "done" then break end + end + end) + check(ok, "stepHealAnim runs") + eq(table.concat(events, ","), + "1ball,31ball,61ball,91jingle,203done", + "heal machine: a ball per mon every 30 frames, jingle, flash, done") + eq(toggles, 8, "machine sprites flash 8 times") + check(ha.visible, "machine sprites end visible") + + -- the wait phase holds until the jingle actually finishes + local ha2 = { balls = 1, lit = 0, timer = 0, visible = true } + local doneEarly = false + pcall(function() + for _ = 1, 300 do + if OW.stepHealAnim(ha2) == "done" then doneEarly = true end + end + end) + check(not doneEarly, "heal machine waits for the jingle to end") + ha2.jingleDone = true + local extra = 0 + pcall(function() + repeat extra = extra + 1 until OW.stepHealAnim(ha2) == "done" or extra > 100 + end) + eq(extra, 32, "32-frame beat after the jingle") +end + +-- ---------------------------------------------------------------- HP bar right cap +-- DrawHPBar (home/pokemon.asm): the right-end tile depends on +-- wHPBarType -- only type 1 (player battle bar, status screen) uses the +-- double-bar $6D; the enemy bar (0) and party menu (2) end with the +-- near-blank $6C nub. +do + local HudTiles = require("src.render.HudTiles") + local ok = pcall(function() + eq(HudTiles.capTile(0), 0x6C, "enemy bar cap is $6C") + eq(HudTiles.capTile(1), 0x6D, "player battle bar cap is $6D") + eq(HudTiles.capTile(2), 0x6C, "party menu bar cap is $6C") + end) + check(ok, "HudTiles.capTile exists") +end + +-- ---------------------------------------------------------------- mart menu flow +-- DisplayPokemartDialogue_ loops the BUY/SELL/QUIT menu until QUIT, so +-- closing the buy list must land back on the mart menu and QUIT must +-- fire onQuit -- open_mart resumes its yielded script runner there, +-- and losing it softlocked the Viridian mart after a purchase. +do + local ShopMenu = require("src.ui.ShopMenu") + local quitCalled = false + local depth0 = #StateStack.states + local shop = ShopMenu.new(Game, { "POTION" }, function() quitCalled = true end) + StateStack:push(shop) + local function press(btn) + Input.pressed = { [btn] = true } + StateStack:update(1 / 60) + Input.pressed = {} + end + press("a") -- BUY + check(StateStack:top() ~= shop, "BUY opens the buy list") + press("b") -- close the list + eq(StateStack:top(), shop, "closing the list returns to the mart menu") + press("down") + press("down") + press("a") -- QUIT + check(quitCalled, "QUIT fires onQuit (script runner resume)") + eq(#StateStack.states, depth0, "mart menu unwound cleanly") +end + + + +-- ================= BUGS.md batch: battle-victory-music ================= +-- FaintEnemyPokemon .wild_win (core.asm:792-795) / TrainerBattleVictory +-- (core.asm:915-933): the looping victory theme starts when the win is +-- decided, not at battle pop; finish() restores the map theme. +do + local savedParty = Game.save.party + local Music = require("src.core.Music") + local Pokemon = require("src.pokemon.Pokemon") + local realPlayVictory, realRestore = Music.playVictory, Music.restoreMap + local restores = 0 + Music.restoreMap = function() restores = restores + 1 end + + -- wild win (level 10: TACKLE in slot 1, so mash-A wins fast) + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local vb = BattleState.newWild(Game, "RATTATA", 2) + local vfin; vb.onFinish = function(r) vfin = r end + StateStack:push(vb) + local calls = {} + Music.playVictory = function(data, kind) + table.insert(calls, { kind = kind, resultAtCall = vb.result, + inBattle = StateStack:top() == vb }) + end + local guard = 0 + while vfin == nil and guard < 20000 do + guard = guard + 1 + Input:keypressed("z"); Input:step(); Input.pressed = { a = true } + StateStack:update(1 / 60); Input:keyreleased("z") + end + eq(vfin, "win", "victory-music wild battle is won") + eq(#calls, 1, "wild win starts the victory theme exactly once") + eq(calls[1] and calls[1].kind, "wild", "wild win uses the DefeatedWildMon theme") + check(calls[1] and calls[1].resultAtCall == nil and calls[1].inBattle, + "wild victory theme starts before the fainted text, in battle") + check(restores >= 1, "finish() restores the map theme at battle pop") + vb:playVictoryMusic() + eq(#calls, 1, "playVictoryMusic is idempotent") + + -- trainer win: theme starts while the defeated/prize texts are queued + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 60) } + local tv = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + tv.enemyParty = { tv.enemyParty[1] } -- single-mon party + local tfin; tv.onFinish = function(r) tfin = r end + StateStack:push(tv) + calls = {} + Music.playVictory = function(data, kind) + local pendingDefeated = false + for _, it in ipairs(tv.queue) do + if it.text and it.text:find("defeated", 1, true) then pendingDefeated = true end + end + table.insert(calls, { kind = kind, pendingDefeated = pendingDefeated }) + end + guard = 0 + while tfin == nil and guard < 20000 do + guard = guard + 1 + Input:keypressed("z"); Input:step(); Input.pressed = { a = true } + StateStack:update(1 / 60); Input:keyreleased("z") + end + eq(tfin, "win", "victory-music trainer battle is won") + eq(#calls, 1, "trainer win starts the victory theme exactly once") + eq(calls[1] and calls[1].kind, "trainer", "plain trainer uses the DefeatedTrainer theme") + check(calls[1] and calls[1].pendingDefeated, + "TrainerBattleVictory: theme starts before the defeated text") + + -- a loss never plays victory music + Game.save.party = { Pokemon.new(Data, "CATERPIE", 2) } + local lb = BattleState.newWild(Game, "SNORLAX", 50) + local lfin; lb.onFinish = function(r) lfin = r end + StateStack:push(lb) + calls = {} + Music.playVictory = function() table.insert(calls, true) end + guard = 0 + while lfin == nil and guard < 20000 do + guard = guard + 1 + Input:keypressed("z"); Input:step(); Input.pressed = { a = true } + StateStack:update(1 / 60); Input:keyreleased("z") + end + eq(lfin, "lose", "loss battle ends in blackout") + eq(#calls, 0, "no victory theme on a loss") + Music.playVictory, Music.restoreMap = realPlayVictory, realRestore + Game.save.party = savedParty +end + +-- ================= BUGS.md batch: battle-caught-fanfare-locked-ball ================= +-- SFX_CAUGHT_MON accompanies ItemUseBallText05 (sound_caught_mon, +-- item_effects.asm:608-614): the fanfare fires with the caught text, +-- after the wobble tinks, not after the message is dismissed; the +-- resting closed ball stays compiled for the caught text (the $43 +-- chain ends after SHAKE_ANIM and the GB leaves the ball in OAM). +do + local savedParty = Game.save.party + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + local log = {} + local origPlay = Sound.play + Sound.play = function(_, name) log[#log + 1] = "sfx:" .. name end + Game.save.pokedex.owned.RATTATA = true + Game.save.party = {} + for _ = 1, 6 do table.insert(Game.save.party, Pokemon.new(Data, "PIDGEY", 5)) end + local cb4 = BattleState.newWild(Game, "RATTATA", 3) + cb4.onFinish = function() end + cb4.rng = function(a, b) return a end -- rng low: guaranteed capture + local origStart = cb4.startMessage + cb4.startMessage = function(s, item) + log[#log + 1] = "text:" .. item.text:gsub("\n.*", "") + return origStart(s, item) + end + cb4.queue = {} + cb4:throwBall("POKE_BALL") + local steps = 0 + while steps < 4000 do + steps = steps + 1 + Input.pressed = { a = true } + if not cb4:updateQueue() then break end + end + Input.pressed = {} + Sound.play = origPlay + local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0 + for i, e in ipairs(log) do + if e == "sfx:Caught_Mon" then + fanfares = fanfares + 1 + fanfareAt = fanfareAt or i + elseif e == "sfx:Tink" then + tinks = tinks + 1 + check(not fanfareAt, "wobble tinks all precede the fanfare") + elseif e == "text:All right!" then + caughtAt = caughtAt or i + end + end + eq(fanfares, 1, "one caught fanfare per capture") + eq(tinks, 3, "three wobble tinks on a $43 capture") + check(fanfareAt and caughtAt and fanfareAt < caughtAt, + "Caught_Mon sounds with the caught text, not after its dismissal") + eq(cb4.result, "caught", "the capture resolved the battle") + check(cb4.lockedBall and #cb4.lockedBall > 0, + "the resting closed ball stays compiled for the caught text") + Game.save.party = savedParty +end + +-- ================= BUGS.md batch: battle-low-health-alarm ================= +-- audio/low_health_alarm.asm + DrawPlayerHUDAndHPBar (core.asm:1846- +-- 1875): the alarm keys off the drawn bar color (red = max(1, +-- floor(hp*48/max)) < 10), gated by the drain, faint, and the win +-- disable (EndLowHealthAlarm sets wLowHealthAlarmDisabled). +do + local savedParty = Game.save.party + local Pokemon = require("src.pokemon.Pokemon") + check(Data.audio.sfx.Low_Health_Alarm ~= nil, "low-health alarm sfx extracted") + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + Game.save.party[1].hp = Game.save.party[1].stats.hp + local lhb = BattleState.newWild(Game, "RATTATA", 3) + local lp = lhb.player + lhb.introSlide, lhb.showPlayerBack = 0, false + lp.mon.stats.hp = 48 + lp.mon.hp, lp.shownHP = 10, 10 + check(not lhb:lowHealthAlarmActive(), "bar at 10 px: yellow, no alarm") + lp.mon.hp, lp.shownHP = 9, 9 + check(lhb:lowHealthAlarmActive(), "bar under 10 px: red, alarm on") + lp.shownHP = 20 + check(not lhb:lowHealthAlarmActive(), "alarm waits for the HP drain to catch up") + lp.shownHP = 9 + lhb.result = "win" + check(not lhb:lowHealthAlarmActive(), "decided battle keeps the alarm off (EndLowHealthAlarm)") + lhb.result = nil + lhb:playVictoryMusic() + check(not lhb:lowHealthAlarmActive(), + "playVictoryMusic disables the alarm (wLowHealthAlarmDisabled)") + lhb.lowHealthAlarmDisabled, lhb.victoryMusicPlayed = nil, nil + lp.mon.hp, lp.shownHP = 0, 0 + check(not lhb:lowHealthAlarmActive(), "fainted mon: no alarm") + lp.mon.stats.hp = 250 + lp.mon.hp, lp.shownHP = 52, 52 + check(lhb:lowHealthAlarmActive(), "52/250 HP = 9 px is red (GetHPBarLength math)") + lp.mon.hp, lp.shownHP = 53, 53 + check(not lhb:lowHealthAlarmActive(), "53/250 HP = 10 px is not red") + Game.save.party = savedParty +end + +-- ================= BUGS.md batch: options-menu ================= +do +-- == Task 8: options screen scrolls option boxes + audio/display rows == +-- The screen keeps pokered's one-box-per-option adaptation of +-- DisplayOptionMenu (engine/menus/main_menu.asm) but now scrolls 11 +-- option boxes through a 4-box viewport with a $EE ▼ marker; MUSIC VOL / +-- SFX VOL clamp at 0..7 like the text-speed cursor clamps at its ends +-- (.pressedLeftInTextSpeed), MUSIC FILTER cycles OFF/1X/2X/3X, and +-- COLORS / TILT / GBC FX cycle their display modes. +do + local OptionsMenu = require("src.ui.OptionsMenu") + local OInput = require("src.core.Input") + local PaletteFX = require("src.render.PaletteFX") + local Tilt = require("src.render.Tilt") + local GBCFX = require("src.render.GBCFX") + local SD = require("src.core.SaveData") + -- Isolate from earlier save/options writes in this suite + SD.saveOptions(SD.defaultOptions()) + local popped = false + local og = { data = Data, save = SD.newGame(), + input = OInput, stack = { pop = function() popped = true end }, + writeOptions = function(self) SD.saveOptions(self.save.options) end } + local om = OptionsMenu.new(og) + local function press(btn) + OInput.pressed = { [btn] = true } + om:update(1 / 60) + OInput.pressed = {} + end + eq(og.save.options.textSpeed, 3, + "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") + eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") + eq(og.save.options.tilt, 0, "new saves default TILT to OFF") + eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") + eq(om.scroll, 0, "options viewport starts at the top") + for _ = 1, 4 do press("down") end + eq(om.index, 5, "cursor reaches MUSIC VOL") + eq(om.scroll, 1, "viewport scrolls to keep MUSIC VOL on screen") + press("left") + eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL") + press("right") + eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back") + press("right") + eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7") + press("down"); press("left") + eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row") + press("down") + for _ = 1, 3 do press("a") end + eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X") + press("a") + eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF") + press("down") + eq(om.index, 8, "cursor reaches COLORS") + press("a") + eq(og.save.options.colors, "og", "A cycles COLORS to OG") + eq(PaletteFX.mode, "og", "PaletteFX mode tracks COLORS option") + for _ = 1, 4 do press("a") end + eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC") + press("down") + eq(om.index, 9, "cursor reaches TILT") + press("a") + eq(og.save.options.tilt, 1, "A cycles TILT to 15") + eq(Tilt.level, 1, "Tilt level tracks TILT option") + press("a"); press("a"); press("a") + eq(og.save.options.tilt, 0, "TILT wraps back to OFF") + press("down") + eq(om.index, 10, "cursor reaches GBC FX") + press("a") + eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1") + eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option") + for _ = 1, 4 do press("a") end + eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") + press("down") + eq(om.index, 11, "CANCEL is row 11") + eq(om.scroll, 6, "CANCEL keeps the last option boxes on screen") + om:draw() -- smoke: scrolled layout draws under the headless stub + press("a") + check(popped, "A on CANCEL closes the options menu") + local om2 = OptionsMenu.new(og) + OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} + eq(om2.index, 11, "up from the top wraps to CANCEL") + eq(om2.scroll, 6, "wrapping to CANCEL scrolls to the tail") + -- headless-safe: no love.audio, setters only update internal state + require("src.core.Music").applyOptions(og.save.options) + require("src.core.Sound").applyOptions(og.save.options) + PaletteFX.applyOptions(og.save.options) + Tilt.applyOptions(og.save.options) + GBCFX.applyOptions(og.save.options) +end +end + +-- ------------------------------------------------------------------ +-- BUGS.md fix coverage (2026-07-14 batch A) +-- ------------------------------------------------------------------ + +-- ================= BUGS.md batch: menu-sfx ================= +do +-- == Task 6: menu SFX paths stay headless-safe (HandleMenuInput_ A|B beep) == +local Menu = require("src.ui.Menu") +local ChoiceBox = require("src.ui.ChoiceBox") +do + local function stubGame(pressed) + local popped = 0 + local game = { + data = Data, + -- like Input:wasPressed, edges hold for the whole step (no consume) + input = { wasPressed = function(_, key) return pressed[key] or false end }, + stack = {}, + } + game.stack.pop = function() popped = popped + 1 end + game.popCount = function() return popped end + return game + end + + local game = stubGame({ a = true }) + local fired = false + local menu = Menu.new(game, { { label = "X", onSelect = function() fired = true end } }) + menu:update(0) + check(fired, "Menu A-press selects (SFX no-ops headless)") + eq(game.popCount(), 1, "Menu A-press pops itself") + + game = stubGame({ b = true }) + local canceled = false + menu = Menu.new(game, { { label = "X" } }, { onCancel = function() canceled = true end }) + menu:update(0) + check(canceled, "Menu B-press cancels (SFX no-ops headless)") + + -- pokered's wMenuWatchedKeys mask varies per menu: the common PAD_A | + -- PAD_B (and the list menu's + PAD_SELECT) masks omit PAD_START, so a + -- default menu ignores START; only menus whose real mask adds PAD_START + -- (the start menu, engine/menus/draw_start_menu.asm) opt in. + game = stubGame({ start = true }) + menu = Menu.new(game, { { label = "X" } }) + menu:update(0) + eq(game.popCount(), 0, "Menu START-press is ignored (mask omits PAD_START)") + + game = stubGame({ start = true }) + menu = Menu.new(game, { { label = "X" } }, { startCloses = true }) + menu:update(0) + eq(game.popCount(), 1, "Menu START-press closes when startCloses (start menu's PAD_START mask; no beep per HandleMenuInput_)") + + game = stubGame({ a = true }) + local yes + local box = ChoiceBox.new(game, function(v) yes = v end) + box:update(0) + eq(yes, true, "ChoiceBox A on YES chooses true") + + game = stubGame({ b = true }) + local no + box = ChoiceBox.new(game, function(v) no = v end) + box:update(0) + eq(no, false, "ChoiceBox B chooses false") +end +end + +-- ================= BUGS.md batch: quit-confirm ================= +do +-- ---------------------------------------------------------------- START menu QUIT -> title +do + local StartMenuQ = require("src.ui.StartMenu") + local qsave = require("src.core.SaveData").newGame() + local qstack = { states = {} } + function qstack:push(s) table.insert(self.states, s) end + function qstack:pop() return table.remove(self.states) end + function qstack:top() return self.states[#self.states] end + local qpressed = {} + local qreturned = 0 + local qg = { + data = Data, save = qsave, stack = qstack, + input = { wasPressed = function(_, k) return qpressed[k] end }, + returnToTitle = function() qreturned = qreturned + 1 end, + } + local qmenu = StartMenuQ.new(qg) + local quitIdx, hasExit + for i, it in ipairs(qmenu.items) do + if it.label == "QUIT" then quitIdx = i end + if it.label == "EXIT" then hasExit = true end + end + check(quitIdx ~= nil, "START menu lists QUIT") + check(not hasExit, "EXIT entry is gone") + qstack:push(qmenu) + qmenu.index = quitIdx + qpressed = { a = true } + qmenu:update(1 / 60) + qpressed = {} + eq(qsave.startMenuIndex, quitIdx, "QUIT selection persists the cursor slot") + local qbox = qstack:top() + check(qbox ~= qmenu and qbox ~= nil and qbox.pages ~= nil, + "QUIT pushes a confirmation textbox") + eq(qbox.pages[1][1], "RETURN TO MAIN", "confirm asks RETURN TO MAIN MENU?") + qbox.onDone() + local qchoice = qstack:top() + check(qchoice ~= qbox and qchoice ~= nil and qchoice.onChoose ~= nil, + "textbox is followed by a YES/NO choice") + eq(qchoice.index, 2, "QUIT confirm defaults to NO") + qchoice.onChoose(false) + eq(qreturned, 0, "NO keeps playing") + qchoice.onChoose(true) + eq(qreturned, 1, "YES calls returnToTitle") + + -- Game:returnToTitle pops everything and pushes a fresh title + local GameQ = require("src.core.Game") + local tstack = { states = { {}, {}, {} } } + function tstack:push(s) table.insert(self.states, s) end + function tstack:pop() return table.remove(self.states) end + function tstack:top() return self.states[#self.states] end + local tg = { data = Data, stack = tstack, + makeTitleState = GameQ.makeTitleState } + local okTitle = pcall(GameQ.returnToTitle, tg) + check(okTitle, "returnToTitle runs headless") + if okTitle then + eq(#tstack.states, 1, "returnToTitle leaves only the title state") + check(tstack.states[1].onNewGame ~= nil, + "fresh title carries the NEW GAME wiring") + end +end +end + +-- ================= BUGS.md batch: give-item ================= + +-- ================= BUGS.md batch: give-item ================= +do +-- == Task 9: give_item announces the received item == +-- pokered's GiveItem (home/give.asm) fills wStringBuffer and every gift +-- script prints a text ending " got\n!"; give_item's +-- default box uses that generic wording, an optional 4th arg picks a +-- per-script text, and false suppresses the box entirely. +do + local ScriptCommands = require("src.script.Commands") + local pushed = {} + local giftSave = { inventory = {}, bagOrder = {}, player = { name = "RED" } } + local fakeGame = { data = Data, save = giftSave, + stack = { push = function(_, s) table.insert(pushed, s) end } } + local stubRunner = { yield = function() end, resume = function() end } + local ctx = { game = fakeGame, save = giftSave, runner = stubRunner } + local ret = ScriptCommands.give_item(ctx, "POTION", 1) + eq(ret, nil, "give_item success returns nil (script continues)") + eq(giftSave.inventory.POTION, 1, "give_item adds to the bag") + eq(#pushed, 1, "give_item pushes the got-item textbox") + local boxText = pushed[1] and table.concat(pushed[1].pages[1], "\n") or "" + check(boxText:find("got", 1, true) ~= nil, "got-item box says got") + check(boxText:find("RED", 1, true) ~= nil, "got-item box names the player") + check(boxText:find(Data.items.POTION.name, 1, true) ~= nil, "got-item box names the item") + eq(fakeGame.stringBuffer, Data.items.POTION.name, + "give_item fills the wStringBuffer analog") + -- gotText = false: the script prints its own received row; no box + ScriptCommands.give_item(ctx, "S_S_TICKET", 1, false) + eq(giftSave.inventory.S_S_TICKET, 1, "suppressed give still adds to the bag") + eq(#pushed, 1, "gotText=false pushes no box") + -- gotText label: authentic per-script text, {RAM:wStringBuffer} filled + ScriptCommands.give_item(ctx, "TOWN_MAP", 1, "_GotMapText") + eq(#pushed, 2, "gotText label pushes the authentic box") + local mapText = pushed[2] and table.concat(pushed[2].pages[1], "\n") or "" + check(mapText:find("TOWN MAP", 1, true) ~= nil, + "{RAM:wStringBuffer} renders the item name") +end +end + +-- ================= BUGS.md batch: ledge-shadow ================= +do +-- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block == +-- LoadHoppingShadowOAM (engine/overworld/ledges.asm) writes the 8x8 +-- shadow tile as a 2x2 OAM block -- normal, X-flip, Y-flip, XY-flip +-- (LedgeHoppingShadowOAMBlock) -- at OAM y=$54, x=$48: screen (64,68), +-- the ground cell's left edge, 4px below its top. +do + local Player = require("src.world.Player") + local p = Player.new(Data, 5, 6, "down") + check(p.shadowImg ~= nil, "hop shadow image loads") + p.hopFrames, p.hopTotal = 32, 32 + local calls = {} + local origDraw = love.graphics.draw + love.graphics.draw = function(img, x, y, r, sxs, sys) + if img == p.shadowImg then + calls[#calls + 1] = { x, y, sxs or 1, sys or 1 } + end + end + -- camera centered on the player, like Camera:follow at 160x144 + p:draw(p.px - 64, p.py - 64) + love.graphics.draw = origDraw + eq(#calls, 4, "hop shadow drawn as a 2x2 OAM block") + local want = { + { 64, 68, 1, 1 }, -- upper left + { 80, 68, -1, 1 }, -- upper right, X-flipped + { 64, 84, 1, -1 }, -- lower left, Y-flipped + { 80, 84, -1, -1 }, -- lower right, XY-flipped + } + for i, w in ipairs(want) do + local c = calls[i] or {} + check(c[1] == w[1] and c[2] == w[2] and c[3] == w[3] and c[4] == w[4], + ("hop shadow quadrant %d at (%s,%s) scale (%s,%s)") + :format(i, tostring(c[1]), tostring(c[2]), + tostring(c[3]), tostring(c[4]))) + end +end +end + +-- ================= BUGS.md batch: border-tree ================= +do +-- ---------------------------------------------------------------- border fill (tree wall) +local TileRenderer = require("src.render.TileRenderer") +-- OVERWORLD maps fill beyond-edge space with the solid tree wall $0F +-- (ViridianCity/CeruleanCity/CeladonCity border_block: four regular-tree +-- metatiles); per-map borders like Pallet's all-grass $0B (the +-- CutTreeBlockSwaps $0B->$0A block) only apply to other tilesets +eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F, + "OVERWORLD border fill uses the tree wall block") +eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, + "interior border fill keeps the map's border block") +eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F, + "Pallet Town border fill is trees, not its all-grass border block") +local treeWallCuttable = false +for _, swap in ipairs(Data.field.cutTreeSwaps) do + if swap.before == 0x0F then treeWallCuttable = true end +end +check(not treeWallCuttable, "tree wall block is not a cut-tree swap source") +end + +-- ================= BUGS.md batch: overworld-group ================= +do +-- ---------------------------------------------------------------- neighbor graph & npc pool +do + local OW = require("src.world.OverworldController") + local function find(list, id) + for _, n in ipairs(list) do if n.id == id then return n end end + return nil + end + local one = OW.computeNeighbors(Data.maps, "PALLET_TOWN", 1) + check(find(one, "ROUTE_1") and find(one, "ROUTE_21"), + "one hop reaches Pallet's direct connections") + check(not find(one, "VIRIDIAN_CITY"), "one hop stops before Viridian") + eq(find(one, "ROUTE_1").oy, -Data.maps.ROUTE_1.height * 32, + "north strip sits its full height above") + + local two = OW.computeNeighbors(Data.maps, "PALLET_TOWN", 2) + local r1 = find(two, "ROUTE_1") + local vc = find(two, "VIRIDIAN_CITY") + check(vc, "two hops reach Viridian via Route 1") + check(not find(two, "PALLET_TOWN"), "the current map is never a neighbor") + eq(vc.ox, r1.ox + Data.maps.ROUTE_1.connections.north.offset * 32, + "two-hop x offset composes the Route 1 -> Viridian alignment") + eq(vc.oy, r1.oy - Data.maps.VIRIDIAN_CITY.height * 32, + "two-hop y offset stacks Viridian above Route 1") + local counts = {} + local dup = false + for _, n in ipairs(two) do + counts[n.id] = (counts[n.id] or 0) + 1 + if counts[n.id] > 1 then dup = true end + end + check(not dup, "neighbors deduped by map id") + + -- NPC pool: same map object -> same instance, so ghost wander + -- positions survive becoming the real NPCs at a crossing + local pool = {} + local obj = Data.maps.ROUTE_1.objects[1] + local a = OW.pooledNPC(pool, Data, "ROUTE_1", obj) + a.cellX, a.cellY, a.facing = a.cellX + 1, a.cellY + 2, "left" + local b = OW.pooledNPC(pool, Data, "ROUTE_1", obj) + check(rawequal(a, b), "pool reuses the NPC instance per map object") + eq(b.cellX, obj.x + 1, "wandered position carries through the pool") + eq(b.facing, "left", "facing carries through the pool") + local fresh = OW.pooledNPC({}, Data, "ROUTE_1", obj) + eq(fresh.cellX, obj.x, "a fresh pool (warp) respawns at object coords") +end +end + +-- ================= BUGS.md batch: party-icons ================= + +-- ================= BUGS.md batch: party-icons ================= +do +-- ---------------------------------------------------------------- party icons +-- menu_icons.asm dex mapping survives the 16x32 two-frame sheet rebuild +eq(Data.icons.byDex[19], "QUADRUPED", "Rattata icon is QUADRUPED") +eq(Data.icons.byDex[10], "BUG", "Caterpie icon is BUG") +eq(Data.icons.byDex[1], "GRASS", "Bulbasaur icon is GRASS") +eq(Data.icons.byDex[23], "SNAKE", "Ekans icon is SNAKE") +for _, name in ipairs({ "BUG", "GRASS", "SNAKE", "QUADRUPED", "BALL", "HELIX" }) do + local path = Data.icons.icons[name] + check(type(path) == "string", "icon path for " .. name) + local f = io.open(path, "rb") + check(f ~= nil, "icon image exists: " .. tostring(path)) + if f then f:close() end +end + +-- per-icon rest/alt frames (data/icon_pointers.asm MonPartySpritePointers): +-- the base entries are the RESTING frame, the +ICONOFFSET entries the +-- animated alternate. BUG/GRASS rest on Frame2 (sheet index 1) and +-- animate to Frame1; SNAKE/QUADRUPED the reverse. MON/FAIRY/BIRD rest +-- on the overworld walk frame (tile 12 = index 3) and animate to +-- standing; WATER (Seel) is the reverse. BALL/HELIX y-bob instead. +local frameFor = require("src.ui.PartyMenu").frameFor +eq(frameFor("BUG", false), 1, "BUG rests on Frame2") +eq(frameFor("BUG", true), 0, "BUG animates to Frame1") +eq(frameFor("GRASS", false), 1, "GRASS rests on Frame2") +eq(frameFor("GRASS", true), 0, "GRASS animates to Frame1") +eq(frameFor("SNAKE", false), 0, "SNAKE rests on Frame1") +eq(frameFor("SNAKE", true), 1, "SNAKE animates to Frame2") +eq(frameFor("QUADRUPED", false), 0, "QUADRUPED rests on Frame1") +eq(frameFor("QUADRUPED", true), 1, "QUADRUPED animates to Frame2") +eq(frameFor("MON", false), 3, "MON rests on the walk frame (tile 12)") +eq(frameFor("MON", true), 0, "MON animates to standing (tile 0)") +eq(frameFor("FAIRY", false), 3, "FAIRY rests on the walk frame") +eq(frameFor("FAIRY", true), 0, "FAIRY animates to standing") +eq(frameFor("BIRD", false), 3, "BIRD rests on the walk frame") +eq(frameFor("BIRD", true), 0, "BIRD animates to standing") +eq(frameFor("WATER", false), 0, "WATER (Seel) rests standing") +eq(frameFor("WATER", true), 3, "WATER animates to the walk frame") +-- icons outside the table keep the old uniform fallback +eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3") +eq(frameFor("HELIX", true, 32), 1, "fallback: 16x32 sheet animates to 1") +end + +-- ---------------------------------------------- parity workstream tests +-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, +-- error()s if any assertion fails). We dofile the ones that exist here so +-- `luajit tests/run_tests.lua` stays the single green bar; absent files +-- are skipped. +for _, name in ipairs({ "D", "F", "E", "C", "K", "L", "H", "G", "I_M", "B", "J", "A", "flavor", "trainer_sight", "static", "trashcans", "hof", "trade_gift", "intro", "tilt", "gbcfx" }) do + local path = "tests/parity_" .. name .. ".lua" + local fh = io.open(path, "r") + if fh then + fh:close() + local ok, err = pcall(dofile, path) + check(ok, "parity_" .. name .. (ok and " suite" or (": " .. tostring(err)))) + end +end + +print(("\n%s"):format(failures == 0 and "ALL TESTS PASSED" or failures .. " FAILURES")) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua new file mode 100644 index 00000000..12650ca9 --- /dev/null +++ b/tests/save_editor_task6_tests.lua @@ -0,0 +1,261 @@ +-- Headless tests for the Task 6 Boxes + Items save-editor panels. +-- Run from repo root: lua5.4 tests/save_editor_task6_tests.lua +-- (Standalone: does not require editing tests/run_save_editor_tests.lua.) + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love_stub + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor task 6 tests (Boxes + Items) ==") + +local Data = require("src.core.Data") +Data:load() + +local Catalog = require("Catalog") +local MonOps = require("MonOps") +local State = require("State") +local SaveData = require("src.core.SaveData") +local Kit = require("Kit") +local BoxesMod = require("src.pokemon.Boxes") +local PartyMod = require("src.pokemon.Party") +local Bag = require("src.inventory.Bag") + +local Boxes = require("Boxes") +local Items = require("Items") + +local function newState() + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + BoxesMod.ensure(S.save) + return S +end + +-- Boxes panel --------------------------------------------------------- + +do + local S = newState() + local px, py = 12, 80 + + -- Add new mon to box 1 + local listH = BoxesMod.CAPACITY * 18 + local actionsY = py + 34 + listH + 10 + Kit.beginFrame(px + 220 + 10, actionsY + 10, true) -- "Add new mon" button + Boxes.draw(S, Kit, px, py) + eq(#S.save.boxes[1], 1, "Boxes Add new mon appends to box 1") + check(S.dirty == true, "Boxes Add new mon marks dirty") + S.dirty = false + + -- Select the mon in the box list (row 1) to set editingMon + Kit.beginFrame(px + 10, py + 34 + 5, true) -- row 1 of the box list + Boxes.draw(S, Kit, px, py) + eq(S.selectedBoxSlot, 1, "Boxes list click selects slot") + check(S.editingMon == S.save.boxes[1][1], "Boxes list click sets editingMon") + + -- Withdraw the selected box mon into the (empty) party + Kit.beginFrame(px + 10, actionsY + 10, true) -- "Withdraw" button + Boxes.draw(S, Kit, px, py) + eq(#S.save.party, 1, "Boxes Withdraw moves mon into party") + eq(#S.save.boxes[1], 0, "Boxes Withdraw removes mon from box") + + -- Deposit that party mon back into the box + local depositY = actionsY + 40 + Kit.beginFrame(px + 440 + 10, depositY + 10, true) -- "Deposit" button + Boxes.draw(S, Kit, px, py) + eq(#S.save.party, 0, "Boxes Deposit removes mon from party") + eq(#S.save.boxes[1], 1, "Boxes Deposit places mon back in box 1") + + -- Release the mon from the box + Kit.beginFrame(px + 110 + 10, actionsY + 10, true) -- "Release" button + Boxes.draw(S, Kit, px, py) + eq(#S.save.boxes[1], 0, "Boxes Release removes mon from box") + check(S.editingMon == nil, "Boxes Release clears editingMon for released mon") + + -- Box navigation with "<" / ">" + eq(S.selectedBox, 1, "starts on box 1") + Kit.beginFrame(px + 230 + 10, py + 10, true) -- ">" button + Boxes.draw(S, Kit, px, py) + eq(S.selectedBox, 2, "Boxes '>' advances to box 2") + Kit.beginFrame(px + 10, py + 10, true) -- "<" button + Boxes.draw(S, Kit, px, py) + eq(S.selectedBox, 1, "Boxes '<' returns to box 1") +end + +do + -- Withdraw refuses when the party is full + local S = newState() + for i = 1, PartyMod.MAX do + table.insert(S.save.party, MonOps.create(Data, "RATTATA", 5)) + end + table.insert(S.save.boxes[1], MonOps.create(Data, "PIDGEY", 5)) + + local px, py = 12, 80 + local listH = BoxesMod.CAPACITY * 18 + local actionsY = py + 34 + listH + 10 + Kit.beginFrame(px + 10, actionsY + 10, true) -- "Withdraw" button + Boxes.draw(S, Kit, px, py) + eq(#S.save.party, PartyMod.MAX, "Boxes Withdraw is a no-op when party is full") + eq(#S.save.boxes[1], 1, "Boxes Withdraw leaves mon in box when party is full") +end + +-- Items panel ----------------------------------------------------------- + +do + local S = newState() + local px, py = 12, 80 + + local moneyBefore = S.save.money + local moneyBtnY = py + 22 + Kit.beginFrame(px + 132 + 10, moneyBtnY + 10, true) -- "+10" button + Items.draw(S, Kit, px, py) + eq(S.save.money, moneyBefore + 10, "Items +10 money button") + check(S.dirty == true, "Items money change marks dirty") + S.dirty = false + + Kit.beginFrame(px + 10, moneyBtnY + 10, true) -- "-100" button (money >= 0 clamp) + Items.draw(S, Kit, px, py) + eq(S.save.money, moneyBefore + 10 - 100 < 0 and 0 or moneyBefore + 10 - 100, + "Items -100 money button clamps at 0") + + -- Item picker cycles and adds to bag / PC + local pickerY = moneyBtnY + 40 + local pickIdBefore = S.cat.items[S.itemPickerIdx or 1] + Kit.beginFrame(px + 280 + 10, pickerY + 10, true) -- ">" cycles picker + Items.draw(S, Kit, px, py) + check(S.cat.items[S.itemPickerIdx] ~= pickIdBefore or #S.cat.items == 1, + "Items picker '>' advances selection") + + -- point the picker at a known item id for deterministic add/remove checks + for i, id in ipairs(S.cat.items) do + if id == "MASTER_BALL" then S.itemPickerIdx = i break end + end + Kit.beginFrame(px + 320 + 10, pickerY + 10, true) -- "Add to Bag" + Items.draw(S, Kit, px, py) + eq(S.save.inventory.MASTER_BALL, 1, "Items Add to Bag adds MASTER_BALL to inventory") + check(S.dirty == true, "Items Add to Bag marks dirty") + S.dirty = false + + Kit.beginFrame(px + 440 + 10, pickerY + 10, true) -- "Add to PC" + Items.draw(S, Kit, px, py) + eq(S.save.pcItems.MASTER_BALL, 1, "Items Add to PC adds MASTER_BALL to pcItems") + + -- Bag list remove + local bagLabelY = pickerY + 40 + local bagListY = bagLabelY + 20 + local bagPagerY = bagListY + 200 + 8 + local bagActionsY = bagPagerY + 34 + local order = Bag.order(S.save) + local idx = nil + for i, id in ipairs(order) do if id == "MASTER_BALL" then idx = i end end + check(idx ~= nil, "MASTER_BALL present in bag order") + S.selectedBagIdx = idx + Kit.beginFrame(px + 10, bagActionsY + 10, true) -- "Remove 1" + Items.draw(S, Kit, px, py) + check(S.save.inventory.MASTER_BALL == nil, "Items bag Remove 1 clears single-qty MASTER_BALL") + + -- PC list remove all + S.save.pcItems.MASTER_BALL = 5 + local pcLabelY = bagActionsY + 40 + local pcListY = pcLabelY + 20 + local pcPagerY = pcListY + 200 + 8 + local pcActionsY = pcPagerY + 34 + local pcOrder = {} + for id in pairs(S.save.pcItems) do table.insert(pcOrder, id) end + table.sort(pcOrder) + local pidx = nil + for i, id in ipairs(pcOrder) do if id == "MASTER_BALL" then pidx = i end end + S.selectedPcIdx = pidx + Kit.beginFrame(px + 110 + 10, pcActionsY + 10, true) -- "Remove all" + Items.draw(S, Kit, px, py) + check(S.save.pcItems.MASTER_BALL == nil, "Items PC Remove all clears MASTER_BALL") + + -- Badges toggle directly on inventory + local badgeLabelY = pcActionsY + 40 + local badgeY = badgeLabelY + 20 + check(S.save.inventory.BOULDERBADGE == nil, "BOULDERBADGE starts unset") + Kit.beginFrame(px + 10, badgeY + 10, true) -- first badge button + Items.draw(S, Kit, px, py) + check(S.save.inventory.BOULDERBADGE == true, "Items badge toggle sets inventory flag") + Kit.beginFrame(px + 10, badgeY + 10, true) -- toggle again + Items.draw(S, Kit, px, py) + check(S.save.inventory.BOULDERBADGE == nil, "Items badge toggle clears inventory flag") +end + +do + -- Bag cap: 20 distinct slots max (Bag.add returns false past capacity) + local S = newState() + local px, py = 12, 80 + local pickerY = py + 22 + 40 + for i = 1, Bag.CAPACITY do + S.save.inventory["FILLER_ITEM_" .. i] = 1 + table.insert(Bag.order(S.save), "FILLER_ITEM_" .. i) + end + eq(Bag.slots(S.save), Bag.CAPACITY, "bag pre-filled to capacity") + + for i, id in ipairs(S.cat.items) do + if id == "MASTER_BALL" then S.itemPickerIdx = i break end + end + Kit.beginFrame(px + 320 + 10, pickerY + 10, true) -- "Add to Bag" + Items.draw(S, Kit, px, py) + check(S.save.inventory.MASTER_BALL == nil, "Items Add to Bag refuses a new slot past capacity") +end + +do + -- Bag/PC pagination: Prev/Next reach slots beyond the first VISIBLE_ROWS + -- (10), so all 20 bag slots stay selectable (Important fix #1). + local S = newState() + local px, py = 12, 80 + local moneyBtnY = py + 22 + local pickerY = moneyBtnY + 40 + local bagLabelY = pickerY + 40 + local bagListY = bagLabelY + 20 + local bagPagerY = bagListY + 200 + 8 + + for i = 1, Bag.CAPACITY do + local id = "FILLER_ITEM_" .. i + S.save.inventory[id] = 1 + table.insert(Bag.order(S.save), id) + end + + Kit.beginFrame(0, 0, false) + Items.draw(S, Kit, px, py) + eq(S.bagScroll, 0, "Bag list starts on page 1 (unscrolled)") + + Kit.beginFrame(px + 100 + 10, bagPagerY + 10, true) -- "Next" + Items.draw(S, Kit, px, py) + eq(S.bagScroll, 10, "Bag 'Next' pager advances by VISIBLE_ROWS") + + -- Row 10 of page 2 (scroll=10) is absolute slot 20, the last bag slot. + Kit.beginFrame(px + 10, bagListY + 9 * 20 + 5, true) + Items.draw(S, Kit, px, py) + eq(S.selectedBagIdx, 20, "Bag list click on page 2 reaches slot 20") + + Kit.beginFrame(px + 10, bagPagerY + 34 + 10, true) -- "Remove 1" on slot 20 + Items.draw(S, Kit, px, py) + check(S.save.inventory.FILLER_ITEM_20 == nil, "Bag Remove 1 clears the paged-to slot") + + Kit.beginFrame(px + 10, bagPagerY + 10, true) -- "Prev" + Items.draw(S, Kit, px, py) + eq(S.bagScroll, 0, "Bag 'Prev' pager returns to page 1") +end + +print(string.format("save editor task 6 tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_task7_tests.lua b/tests/save_editor_task7_tests.lua new file mode 100644 index 00000000..d7a29bf7 --- /dev/null +++ b/tests/save_editor_task7_tests.lua @@ -0,0 +1,297 @@ +-- Headless tests for the Task 7 Events + Dex panels. +-- Run from repo root: /opt/homebrew/Cellar/lua@5.4/5.4.8/bin/lua tests/save_editor_task7_tests.lua +-- +-- Mirrors tests/run_save_editor_tests.lua's approach: drive Kit's +-- immediate-mode hit-testing by placing the "mouse" at the exact +-- coordinates each panel draws its widgets at (see the layout comments in +-- panels/Events.lua and panels/Dex.lua), so click handlers run for real +-- without a live LOVE window. + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love_stub + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor task 7 tests (Events + Dex) ==") + +local Kit = require("Kit") +local State = require("State") +local SaveIO = require("SaveIO") +local SaveData = require("src.core.SaveData") +local Events = require("Events") +local Dex = require("Dex") + +local px, py = 12, 80 + +-- ===== Events: Flags tab ===== +do + local S = State.new() + S.events = { "EVENT_ALPHA", "EVENT_BEAT_BROCK", "EVENT_ZETA" } + S.save = { + flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {}, + party = {}, boxes = {}, + } + + Kit.beginFrame(0, 0, false) + Events.draw(S, Kit, px, py) + eq(S.eventFilter, "", "Events.draw defaults eventFilter to empty string") + eq(S.eventsTab, "flags", "Events.draw defaults eventsTab to flags") + + local listY = py + 64 + 32 -- contentY(+64) + list offset(+32) + + Kit.beginFrame(px + 10, listY + 10, true) -- row 1: EVENT_ALPHA + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_ALPHA == true, "Flags row1 checkbox sets EVENT_ALPHA") + check(S.dirty == true, "Flags checkbox toggle marks dirty") + S.dirty = false + + Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row 2: EVENT_BEAT_BROCK + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_BEAT_BROCK == true, "Flags row2 checkbox sets EVENT_BEAT_BROCK") + + Kit.beginFrame(px + 10, listY + 22 + 10, true) -- click row 2 again to uncheck + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_BEAT_BROCK == nil, "Unchecking a flag clears the key (not just false)") + + -- re-check it, then persist through SaveIO to confirm it round-trips to disk + Kit.beginFrame(px + 10, listY + 22 + 10, true) + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_BEAT_BROCK == true, "Flags row2 re-checked") + + local path = os.tmpname() .. "-task7-events.lua" + local ok, err = SaveIO.save(path, S.save) + check(ok, "SaveIO.save ok: " .. tostring(err)) + local f = io.open(path, "r") + local raw = f:read("*a") + f:close() + check(raw:find("EVENT_BEAT_BROCK") ~= nil, "saved file contains EVENT_BEAT_BROCK key") + local loaded = SaveData.decode(raw) + check(loaded ~= nil and loaded.flags.EVENT_BEAT_BROCK == true, + "reloaded save confirms EVENT_BEAT_BROCK = true") + os.remove(path) +end + +-- ===== Events: filter field + Clear filter ===== +do + local S = State.new() + S.events = { "EVENT_ALPHA", "EVENT_BEAT_BROCK", "EVENT_ZETA" } + S.save = { + flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {}, + party = {}, boxes = {}, + } + S.eventFilter = "beat" -- love.keyboard.isDown always false in love_stub, + -- so setting this directly stands in for typing + + local listY = py + 64 + 32 + Kit.beginFrame(px + 10, listY + 10, true) -- only visible row under the filter + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_BEAT_BROCK == true, "Filtered row1 toggles the filtered-in event") + check(S.save.flags.EVENT_ALPHA == nil, "Filter hides EVENT_ALPHA from row1's slot") + + local clearBtnX, clearBtnY = px + 320, py + 64 + Kit.beginFrame(clearBtnX + 10, clearBtnY + 10, true) -- Clear filter button + Events.draw(S, Kit, px, py) + eq(S.eventFilter, "", "Clear filter button resets eventFilter") +end + +-- ===== Events: Flags pagination ===== +do + local S = State.new() + S.events = {} + for i = 1, 25 do + S.events[i] = string.format("EVENT_%02d", i) + end + S.save = { + flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {}, + party = {}, boxes = {}, + } + + local listY = py + 64 + 32 + local pagerY = listY + 10 * 22 + 8 + + Kit.beginFrame(px + 100 + 10, pagerY + 10, true) -- Next button + Events.draw(S, Kit, px, py) + eq(S.eventsScroll, 10, "Next button scrolls by VISIBLE_ROWS") + + Kit.beginFrame(px + 10, listY + 10, true) -- row1 now maps to EVENT_11 + Events.draw(S, Kit, px, py) + check(S.save.flags.EVENT_11 == true, "Row1 after scrolling toggles the 11th event") + check(S.save.flags.EVENT_01 == nil, "First event untouched after scrolling") + + Kit.beginFrame(px + 10, pagerY + 10, true) -- Prev button + Events.draw(S, Kit, px, py) + eq(S.eventsScroll, 0, "Prev button scrolls back") +end + +-- ===== Events: Trainers tab ===== +do + local S = State.new() + S.events = {} + S.save = { + flags = {}, + defeatedTrainers = { PALLET_TOWN_obj_0 = true, ROUTE1_obj_2 = false }, + itemsTaken = {}, objectToggles = {}, party = {}, boxes = {}, + } + + local tabsY = py + 24 + local trainersTabX = px + 64 + 4 -- after the "Flags" tab (w = 8*5+24 = 64) + Kit.beginFrame(trainersTabX + 10, tabsY + 10, true) + Events.draw(S, Kit, px, py) + eq(S.eventsTab, "trainers", "Trainers tab click switches sub-tab") + + local listY = py + 64 + 32 + Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row2: ROUTE1_obj_2 (sorted after PALLET_TOWN_obj_0) + Events.draw(S, Kit, px, py) + check(S.save.defeatedTrainers.ROUTE1_obj_2 == true, "Trainers checkbox sets known key true") + + local pagerY = listY + 10 * 22 + 8 + local clearAllX = px + 400 + Kit.beginFrame(clearAllX + 10, pagerY + 10, true) -- Clear all trainers + Events.draw(S, Kit, px, py) + check(next(S.save.defeatedTrainers) == nil, "Clear all trainers empties the table") +end + +-- ===== Events: Items taken tab ===== +do + local S = State.new() + S.events = {} + S.save = { + flags = {}, defeatedTrainers = {}, + itemsTaken = { PALLET_TOWN_obj_1 = false }, + objectToggles = {}, party = {}, boxes = {}, + } + + local tabsY = py + 24 + local trainersTabX = px + 64 + 4 + local itemsTabX = trainersTabX + 88 + 4 -- after "Trainers" (w = 8*8+24 = 88) + Kit.beginFrame(itemsTabX + 10, tabsY + 10, true) + Events.draw(S, Kit, px, py) + eq(S.eventsTab, "items", "Items taken tab click switches sub-tab") + + local listY = py + 64 + 32 + Kit.beginFrame(px + 10, listY + 10, true) -- row1: PALLET_TOWN_obj_1 + Events.draw(S, Kit, px, py) + check(S.save.itemsTaken.PALLET_TOWN_obj_1 == true, "Items checkbox sets known key true") +end + +-- ===== Events: Object toggles tab ===== +do + local S = State.new() + S.events = {} + S.save = { + flags = {}, defeatedTrainers = {}, itemsTaken = {}, + objectToggles = { PALLET_TOWN = { OAK = false, SIGN = true } }, + party = {}, boxes = {}, + } + + local tabsY = py + 24 + local trainersTabX = px + 64 + 4 + local itemsTabX = trainersTabX + 88 + 4 + local togglesTabX = itemsTabX + 112 + 4 -- after "Items taken" (w = 8*11+24 = 112) + Kit.beginFrame(togglesTabX + 10, tabsY + 10, true) + Events.draw(S, Kit, px, py) + eq(S.eventsTab, "toggles", "Object toggles tab click switches sub-tab") + + local listY = py + 64 + 32 + -- row1 is the "[PALLET_TOWN]" header (not clickable); row2/3 are OAK, SIGN (sorted) + Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row2: OAK (false -> true) + Events.draw(S, Kit, px, py) + check(S.save.objectToggles.PALLET_TOWN.OAK == true, "Toggle row flips OAK to true") + + Kit.beginFrame(px + 10, listY + 44 + 10, true) -- row3: SIGN (true -> false) + Events.draw(S, Kit, px, py) + check(S.save.objectToggles.PALLET_TOWN.SIGN == false, "Toggle row flips SIGN to false") + + -- clicking the header row (row1) must not error and must not touch data + Kit.beginFrame(px + 10, listY + 10, true) + local ok = pcall(Events.draw, S, Kit, px, py) + check(ok, "Clicking the map header row does not error") +end + +-- ===== Dex panel ===== +do + local S = State.new() + S.cat = { species = { "BULBASAUR", "CHARMANDER", "SQUIRTLE" } } + S.save = { party = {}, boxes = {}, pokedex = { seen = {}, owned = {} } } + + Kit.beginFrame(0, 0, false) + Dex.draw(S, Kit, px, py) + + local seenX, ownedX = px + 220, px + 300 + local listY = py + 64 + 24 + + Kit.beginFrame(seenX + 10, listY + 10, true) -- row1 seen: BULBASAUR + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.seen.BULBASAUR == true, "Dex row1 seen checkbox sets BULBASAUR seen") + + Kit.beginFrame(ownedX + 10, listY + 10, true) -- row1 owned: BULBASAUR + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.owned.BULBASAUR == true, "Dex row1 owned checkbox sets BULBASAUR owned") + + Kit.beginFrame(seenX + 10, listY + 10, true) -- uncheck seen + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.seen.BULBASAUR == nil, "Unchecking seen clears BULBASAUR") + check(S.save.pokedex.owned.BULBASAUR == nil, "Unchecking seen also clears owned (can't own unseen)") + + S.save.party = { { species = "CHARMANDER" } } + S.save.boxes = { { { species = "SQUIRTLE" } } } + Kit.beginFrame(px + 10, py + 24 + 10, true) -- Own party+boxes + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.owned.CHARMANDER == true, "Own party+boxes marks party mon owned") + check(S.save.pokedex.owned.SQUIRTLE == true, "Own party+boxes marks boxed mon owned") + + Kit.beginFrame(px + 190 + 10, py + 24 + 10, true) -- See all + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.seen.BULBASAUR == true, "See all marks every species seen") + + Kit.beginFrame(px + 310 + 10, py + 24 + 10, true) -- Clear + Dex.draw(S, Kit, px, py) + check(next(S.save.pokedex.seen) == nil, "Clear empties seen") + check(next(S.save.pokedex.owned) == nil, "Clear empties owned") +end + +-- ===== Dex pagination ===== +do + local S = State.new() + S.cat = { species = {} } + for i = 1, 25 do + S.cat.species[i] = string.format("SPECIES_%02d", i) + end + S.save = { party = {}, boxes = {}, pokedex = { seen = {}, owned = {} } } + + local seenX = px + 220 + local listY = py + 64 + 24 + local pagerY = listY + 12 * 22 + 8 + + Kit.beginFrame(px + 100 + 10, pagerY + 10, true) -- Next + Dex.draw(S, Kit, px, py) + eq(S.dexScroll, 12, "Dex Next button scrolls by VISIBLE_ROWS") + + Kit.beginFrame(seenX + 10, listY + 10, true) -- row1 -> SPECIES_13 + Dex.draw(S, Kit, px, py) + check(S.save.pokedex.seen.SPECIES_13 == true, "Row1 after scrolling toggles the 13th species") + check(S.save.pokedex.seen.SPECIES_01 == nil, "First species untouched after scrolling") + + Kit.beginFrame(px + 10, pagerY + 10, true) -- Prev + Dex.draw(S, Kit, px, py) + eq(S.dexScroll, 0, "Dex Prev button scrolls back") +end + +print(string.format("save editor task 7 tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_task8_tests.lua b/tests/save_editor_task8_tests.lua new file mode 100644 index 00000000..04013664 --- /dev/null +++ b/tests/save_editor_task8_tests.lua @@ -0,0 +1,268 @@ +-- Headless tests for tools/save-editor/panels/MapBrowser.lua. +-- Run from repo root: lua5.4 tests/save_editor_task8_tests.lua +-- (love_stub lacks push/pop/scale/scissor; MapBrowser skips real +-- rendering under those but still runs all click/button logic, which is +-- what these tests exercise via Kit.beginFrame like the other panels.) + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love_stub + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor task 8 (map browser) tests ==") + +local Data = require("src.core.Data") +Data:load() + +local SaveData = require("src.core.SaveData") +local State = require("State") +local Kit = require("Kit") +local MapBrowser = require("MapBrowser") + +local LIST_W, LIST_H, ROW_H = 200, 300, 20 +local MAX_ROWS = math.floor(LIST_H / ROW_H) +local VIEW_W, VIEW_H = 480, 432 + +local function newState() + local S = State.new() + S.data = Data + S.save = SaveData.newGame() + S.mapId = S.save.player.map -- PALLET_TOWN + return S +end + +local px, py = 12, 80 +local vx, vy = px + LIST_W + 20, py + 24 + +-- ---------------------------------------------------------------- list +do + local S = newState() + local ids = {} + for id in pairs(Data.maps) do table.insert(ids, id) end + table.sort(ids) + check(#ids > 200, "generated data has lots of maps") + + -- click the 3rd row of the map id list -> selects that map, no crash + -- despite love_stub missing push/pop/scale/scissor + Kit.beginFrame(px + 10, py + 24 + 2 * ROW_H + 5, true) + MapBrowser.draw(S, Kit, px, py) + eq(S.mapId, ids[3], "clicking list row 3 selects the 3rd sorted map id") + check(S.mapClickCell == nil, "switching maps clears any selected cell") +end + +do + local S = newState() + -- Next then Prev should return to the first page + Kit.beginFrame(px + 64 + 5, py + 24 + MAX_ROWS * ROW_H + 8 + 5, true) -- Next + MapBrowser.draw(S, Kit, px, py) + eq(S.mapListScroll, MAX_ROWS, "Next advances one page") + + Kit.beginFrame(px + 5, py + 24 + MAX_ROWS * ROW_H + 8 + 5, true) -- Prev + MapBrowser.draw(S, Kit, px, py) + eq(S.mapListScroll, 0, "Prev returns to page 0") +end + +-- ------------------------------------------------------------- click-to-cell +do + local S = newState() -- PALLET_TOWN + -- (5,6) is a known-walkable, non-warp cell (tests/run_tests.lua uses + -- the same ground truth); zoom 2 means 32 screen px per cell. + local mx = vx + 5 * 16 * S.mapZoom + 4 + local my = vy + 6 * 16 * S.mapZoom + 4 + Kit.beginFrame(mx, my, true) + MapBrowser.draw(S, Kit, px, py) + check(S.mapClickCell ~= nil, "clicking inside the viewport selects a cell") + if S.mapClickCell then + eq(S.mapClickCell.cx, 5, "selected cell cx") + eq(S.mapClickCell.cy, 6, "selected cell cy") + end + + -- clicking outside the viewport (e.g. over the list) must not select a cell + S.mapClickCell = nil + Kit.beginFrame(px + 5, py + 5, true) + MapBrowser.draw(S, Kit, px, py) + check(S.mapClickCell == nil, "clicking outside the viewport doesn't select a cell") +end + +-- ---------------------------------------------------------------- set player +do + local S = newState() + S.mapClickCell = { cx = 3, cy = 4 } + local by = vy + VIEW_H + 8 + Kit.beginFrame(vx + 10, by + 22 + 10, true) -- Set player here + MapBrowser.draw(S, Kit, px, py) + eq(S.save.player.map, S.mapId, "Set player here updates player.map") + eq(S.save.player.x, 3, "Set player here updates player.x") + eq(S.save.player.y, 4, "Set player here updates player.y") + check(S.dirty == true, "Set player here marks dirty") +end + +do + local S = newState() + local by = vy + VIEW_H + 8 + Kit.beginFrame(vx + 10, by + 22 + 10, true) -- Set player here, no cell selected + MapBrowser.draw(S, Kit, px, py) + check(S.status:match("Click a cell first"), "Set player here without a selection warns") +end + +-- ------------------------------------------------------------- lastOutdoor +do + local S = newState() -- PALLET_TOWN has connections -> outdoor + S.mapClickCell = { cx = 5, cy = 6 } + local by = vy + VIEW_H + 8 + Kit.beginFrame(vx + 150 + 10, by + 22 + 10, true) -- Set lastOutdoor here + MapBrowser.draw(S, Kit, px, py) + check(S.save.lastOutdoor ~= nil, "Set lastOutdoor here sets lastOutdoor") + if S.save.lastOutdoor then + eq(S.save.lastOutdoor.id, "PALLET_TOWN", "lastOutdoor.id") + eq(S.save.lastOutdoor.x, 5, "lastOutdoor.x") + eq(S.save.lastOutdoor.y, 6, "lastOutdoor.y") + end +end + +do + -- an interior with no connections and not in save.visited -> rejected + local S = newState() + S.mapId = "REDS_HOUSE_1F" + S.mapClickCell = { cx = 1, cy = 1 } + local by = vy + VIEW_H + 8 + Kit.beginFrame(vx + 150 + 10, by + 22 + 10, true) + MapBrowser.draw(S, Kit, px, py) + check(S.save.lastOutdoor == nil, "Set lastOutdoor here refuses a non-outdoor map") + check(S.status:match("outdoor"), "status explains the refusal") +end + +-- ---------------------------------------------------------------- lastHeal +do + local S = newState() + S.mapClickCell = { cx = 2, cy = 8 } + local by = vy + VIEW_H + 8 + Kit.beginFrame(vx + 320 + 10, by + 22 + 10, true) -- Set lastHeal here + MapBrowser.draw(S, Kit, px, py) + check(S.save.lastHeal ~= nil, "Set lastHeal here sets lastHeal") + eq(S.save.lastHeal.map, S.mapId, "lastHeal.map") + eq(S.save.lastHeal.x, 2, "lastHeal.x") + eq(S.save.lastHeal.y, 8, "lastHeal.y") +end + +-- --------------------------------------------------------------- warp jump +do + local S = newState() + S.mapId = "PALLET_TOWN" + local map = require("src.world.MapLoader").load(Data, "PALLET_TOWN") + check(#map.def.warps > 0, "Pallet Town has warps to test with") + local w = map.def.warps[1] + + local mx = vx + w.x * 16 * S.mapZoom + 4 + local my = vy + w.y * 16 * S.mapZoom + 4 + Kit.beginFrame(mx, my, true) + MapBrowser.draw(S, Kit, px, py) + check(S.mapId ~= "PALLET_TOWN" or w.destMap == "PALLET_TOWN", + "clicking a warp cell jumps S.mapId to its destination") + check(S.status:match("Followed warp"), "warp click sets a status message") +end + +do + -- LAST_MAP warp with no remembered outdoor map must not crash, and + -- must not silently move the view. + local S = newState() + S.mapId = "REDS_HOUSE_1F" + local MapLoader = require("src.world.MapLoader") + local map = MapLoader.load(Data, "REDS_HOUSE_1F") + local lastMapWarp + for _, w in ipairs(map.def.warps) do + if w.destMap == "LAST_MAP" then lastMapWarp = w end + end + if lastMapWarp then + S.save.lastOutdoor = nil + local mx = vx + lastMapWarp.x * 16 * S.mapZoom + 4 + local my = vy + lastMapWarp.y * 16 * S.mapZoom + 4 + Kit.beginFrame(mx, my, true) + local ok = pcall(MapBrowser.draw, S, Kit, px, py) + check(ok, "LAST_MAP warp with no lastOutdoor doesn't crash") + eq(S.mapId, "REDS_HOUSE_1F", "LAST_MAP warp with no lastOutdoor doesn't move the view") + check(S.status:match("lastOutdoor"), "status explains the skipped warp") + else + check(true, "REDS_HOUSE_1F has no LAST_MAP warp to test (skipped)") + end +end + +do + -- Indigo Plateau uses tileset PLATEAU -> plateau.png (not indigo.png). + -- Following its lobby door must remember lastOutdoor so the lobby's + -- LAST_MAP mats return here (same as the game's outsideTilesets). + local S = newState() + S.mapId = "INDIGO_PLATEAU" + S.save.lastOutdoor = { id = "ROUTE_22", x = 8, y = 5 } + local MapLoader = require("src.world.MapLoader") + local indigo = MapLoader.load(Data, "INDIGO_PLATEAU") + eq(indigo.tileset.image, "assets/generated/tilesets/plateau.png", + "Indigo Plateau tileset image is plateau.png") + local door = indigo.def.warps[1] + local mx = vx + door.x * 16 * S.mapZoom + 4 + local my = vy + door.y * 16 * S.mapZoom + 4 + Kit.beginFrame(mx, my, true) + MapBrowser.draw(S, Kit, px, py) + eq(S.mapId, "INDIGO_PLATEAU_LOBBY", "Indigo door warp jumps to the lobby") + check(S.save.lastOutdoor and S.save.lastOutdoor.id == "INDIGO_PLATEAU", + "following Indigo door remembers lastOutdoor as INDIGO_PLATEAU") + + local lobby = MapLoader.load(Data, "INDIGO_PLATEAU_LOBBY") + local exitWarp + for _, w in ipairs(lobby.def.warps) do + if w.destMap == "LAST_MAP" then exitWarp = w break end + end + check(exitWarp ~= nil, "Indigo lobby has a LAST_MAP exit") + -- re-zero the camera so the exit-cell click math matches cellAtScreen + S.mapCamX, S.mapCamY = 0, 0 + mx = vx + exitWarp.x * 16 * S.mapZoom + 4 + my = vy + exitWarp.y * 16 * S.mapZoom + 4 + Kit.beginFrame(mx, my, true) + MapBrowser.draw(S, Kit, px, py) + eq(S.mapId, "INDIGO_PLATEAU", "Indigo lobby LAST_MAP exit returns to the plateau") +end + +-- --------------------------------------------------------- zoom / pan input +do + local S = newState() + local z0 = S.mapZoom + MapBrowser.wheelmoved(S, 1) + check(S.mapZoom > z0, "wheelmoved(+) zooms in") + MapBrowser.wheelmoved(S, -1) + MapBrowser.wheelmoved(S, -1) + check(S.mapZoom < z0, "wheelmoved(-) zooms out") + + S.mapZoom = 1 + for _ = 1, 20 do MapBrowser.wheelmoved(S, -1) end + check(S.mapZoom >= 1, "zoom clamps at a minimum") +end + +do + local S = newState() + S.mapCamX, S.mapCamY = 0, 0 + MapBrowser.keypressed(S, "d") + eq(S.mapCamX, 16, "keypressed d pans camera right by one cell") + MapBrowser.keypressed(S, "down") + eq(S.mapCamY, 16, "keypressed down pans camera down by one cell") + MapBrowser.keypressed(S, "unrelatedkey") + eq(S.mapCamX, 16, "unrelated keys don't pan the camera") +end + +print(string.format("save editor task 8 tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tools/build_data.py b/tools/build_data.py new file mode 100755 index 00000000..08360cb9 --- /dev/null +++ b/tools/build_data.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Build the port's generated data and graphics from a Pokemon Red ROM. + +The public build requires only a canonical US Pokemon Red ROM and Pillow. +Assembly-erased names and the small address subset used by the extractor +are bundled in tools/rom_manifest.json. +""" + +from build_rom_data import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py new file mode 100755 index 00000000..4e1813a6 --- /dev/null +++ b/tools/build_rom_data.py @@ -0,0 +1,1904 @@ +#!/usr/bin/env python3 +"""Build game data directly from a canonical Pokemon Red ROM. + +It accepts one user-provided, canonical US Pokemon Red ROM. Symbol +addresses and assembly-erased names are bundled as non-ROM metadata, so no +pret/pokered checkout, RGBDS build, or external .sym file is required. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import shutil +import sys +from collections import deque + +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from extract import util # noqa: E402 +from rom_data import (RomImage, SymbolTable, bcd, decode_text, # noqa: E402 + decompress_pic, load_manifest, read_string) + + +DATASETS = ( + "constants", "tilesets", "maps", "font", "sprites", "moves", "items", + "type_chart", "palettes", "icons", "pokemon", "trainers", "encounters", + "text", "field", "battle_anims", +) + +GB_SHADES = ( + (255, 255, 255, 255), + (170, 170, 170, 255), + (85, 85, 85, 255), + (0, 0, 0, 255), +) + + +def _symbol(symbols, name): + try: + return symbols[name] + except KeyError as exc: + raise ValueError(f"required symbol {name!r} is missing") from exc + + +def extract_constants(manifest, out_dir): + data = manifest["constants"] + util.write_lua( + os.path.join(out_dir, "constants.lua"), data, + header="Source: canonical Pokemon Red ROM metadata manifest") + return data + + +def _read_terminated(rom, bank, address, terminator, limit=256): + out = [] + for offset in range(limit): + value = rom.byte(bank, address + offset) + if value == terminator: + return out + out.append(value) + raise ValueError( + f"unterminated byte list at {bank:02x}:{address:04x}") + + +def _decode_2bpp(raw, width, height, transparent_color0=False): + if width % 8 or height % 8: + raise ValueError(f"2bpp dimensions must be tile-aligned: {width}x{height}") + tile_count = width // 8 * (height // 8) + if len(raw) != tile_count * 16: + raise ValueError( + f"2bpp payload is {len(raw)} bytes, expected {tile_count * 16}") + + image = Image.new("RGBA", (width, height)) + pixels = image.load() + tiles_per_row = width // 8 + for tile in range(tile_count): + tile_x = (tile % tiles_per_row) * 8 + tile_y = (tile // tiles_per_row) * 8 + for y in range(8): + low = raw[tile * 16 + y * 2] + high = raw[tile * 16 + y * 2 + 1] + for x in range(8): + bit = 7 - x + shade = ((high >> bit) & 1) * 2 + ((low >> bit) & 1) + color = GB_SHADES[shade] + if transparent_color0 and shade == 0: + color = (255, 255, 255, 0) + pixels[tile_x + x, tile_y + y] = color + return image + + +def _decode_1bpp(raw, width, height, transparent_color0=False): + if width % 8 or height % 8: + raise ValueError(f"1bpp dimensions must be tile-aligned: {width}x{height}") + tile_count = width // 8 * (height // 8) + if len(raw) != tile_count * 8: + raise ValueError( + f"1bpp payload is {len(raw)} bytes, expected {tile_count * 8}") + + image = Image.new("RGBA", (width, height)) + pixels = image.load() + tiles_per_row = width // 8 + for tile in range(tile_count): + tile_x = (tile % tiles_per_row) * 8 + tile_y = (tile // tiles_per_row) * 8 + for y in range(8): + row = raw[tile * 8 + y] + for x in range(8): + filled = bool(row & (1 << (7 - x))) + if filled: + color = (0, 0, 0, 255) + elif transparent_color0: + color = (255, 255, 255, 0) + else: + color = (255, 255, 255, 255) + pixels[tile_x + x, tile_y + y] = color + return image + + +def _columns_to_rows(raw, tiles_wide, tiles_high, bytes_per_tile=16): + out = bytearray(len(raw)) + for y in range(tiles_high): + for x in range(tiles_wide): + source = (x * tiles_high + y) * bytes_per_tile + target = (y * tiles_wide + x) * bytes_per_tile + out[target:target + bytes_per_tile] = \ + raw[source:source + bytes_per_tile] + return bytes(out) + + +def _save_png(image, path): + os.makedirs(os.path.dirname(path), exist_ok=True) + image.save(path, optimize=True) + + +def _matte_color0(image): + pixels = image.load() + width, height = image.size + queue = deque() + seen = set() + + def add(x, y): + if (x, y) not in seen and pixels[x, y] == (255, 255, 255, 255): + seen.add((x, y)) + queue.append((x, y)) + + for x in range(width): + add(x, 0) + add(x, height - 1) + for y in range(height): + add(0, y) + add(width - 1, y) + while queue: + x, y = queue.popleft() + pixels[x, y] = (255, 255, 255, 0) + for next_x, next_y in ( + (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if 0 <= next_x < width and 0 <= next_y < height: + add(next_x, next_y) + return image + + +def _write_2bpp_png( + raw, width, height, path, transparent_color0=False): + _save_png( + _decode_2bpp(raw, width, height, transparent_color0), + path) + + +def _write_compressed_pic(rom, symbols, label, path): + symbol = _symbol(symbols, label) + compressed = rom.bytes( + symbol.bank, symbol.address, 0x8000 - symbol.address) + raw, width = decompress_pic(compressed) + image = _matte_color0( + _decode_2bpp(raw, width * 8, width * 8)) + _save_png(image, path) + return width + + +def extract_tilesets(rom, symbols, manifest, out_dir, assets_dir): + order = manifest["constants"]["tilesetOrder"] + metadata = manifest["tilesets"] + animations = manifest["tileAnimations"] + if len(metadata) != len(order): + raise ValueError("tileset metadata count does not match constants") + + headers = _symbol(symbols, "Tilesets") + warp_pointers = _symbol(symbols, "WarpTileIDPointers") + door_pointers = _symbol(symbols, "DoorTileIDPointers") + + doors = {} + address = door_pointers.address + while True: + tileset_id = rom.byte(door_pointers.bank, address) + if tileset_id == 0xFF: + break + pointer = rom.word(door_pointers.bank, address + 1) + doors[tileset_id] = _read_terminated( + rom, door_pointers.bank, pointer, 0) + address += 3 + + out = {} + written_images = set() + for index, (const_name, spec) in enumerate(zip(order, metadata)): + if spec["id"] != const_name: + raise ValueError( + f"tileset metadata {spec['id']} is out of order at {const_name}") + row_address = headers.address + index * 12 + gfx_bank = rom.byte(headers.bank, row_address) + block_pointer = rom.word(headers.bank, row_address + 1) + gfx_pointer = rom.word(headers.bank, row_address + 3) + collision_pointer = rom.word(headers.bank, row_address + 5) + counters = list(rom.bytes(headers.bank, row_address + 7, 3)) + grass = rom.byte(headers.bank, row_address + 10) + animation_id = rom.byte(headers.bank, row_address + 11) + if animation_id >= len(animations): + raise ValueError( + f"{const_name}: unknown tile animation {animation_id}") + + blocks_raw = rom.bytes( + gfx_bank, block_pointer, spec["blockCount"] * 16) + blocks = [ + list(blocks_raw[offset:offset + 16]) + for offset in range(0, len(blocks_raw), 16) + ] + walkable = sorted(_read_terminated( + rom, 0, collision_pointer, 0xFF)) + warp_pointer = rom.word( + warp_pointers.bank, warp_pointers.address + index * 2) + warp_tiles = sorted(set(_read_terminated( + rom, warp_pointers.bank, warp_pointer, 0xFF))) + + base = spec["imageBase"] + image_path = os.path.join(assets_dir, "tilesets", base + ".png") + if base not in written_images: + byte_length = spec["imageWidth"] * spec["imageHeight"] // 4 + stored_length = block_pointer - gfx_pointer + if stored_length < 0 or stored_length > byte_length \ + or stored_length % 16: + raise ValueError( + f"{const_name}: invalid stored tileset graphics length " + f"{stored_length}") + pixels = rom.bytes(gfx_bank, gfx_pointer, stored_length) + pixels += bytes(byte_length - stored_length) + _write_2bpp_png( + pixels, + spec["imageWidth"], spec["imageHeight"], image_path) + written_images.add(base) + + out[const_name] = { + "id": const_name, + "source": f"ROM:Tilesets[{index}]", + "image": f"assets/generated/tilesets/{base}.png", + "imageWidth": spec["imageWidth"], + "imageHeight": spec["imageHeight"], + "tilesPerRow": spec["imageWidth"] // 8, + "blocks": blocks, + "walkable": walkable, + "counterTiles": [value for value in counters if value != 0xFF], + "grassTile": None if grass == 0xFF else grass, + "doorTiles": sorted(doors.get(index, [])), + "warpTiles": warp_tiles, + "animation": animations[animation_id], + } + + for number in (1, 2, 3): + symbol = _symbol(symbols, f"FlowerTile{number}") + _write_2bpp_png( + rom.bytes(symbol.bank, symbol.address, 16), 8, 8, + os.path.join(assets_dir, "tilesets", f"flower{number}.png")) + spinner = _symbol(symbols, "SpinnerArrowAnimTiles") + _write_2bpp_png( + rom.bytes(spinner.bank, spinner.address, 64), 32, 8, + os.path.join(assets_dir, "tilesets", "spinners.png")) + + util.write_lua( + os.path.join(out_dir, "tilesets.lua"), out, + header="Source: canonical Pokemon Red ROM (Tilesets, blocksets,\n" + "tile graphics, collision/warp/door lists)") + return out + + +def extract_font(rom, symbols, manifest, out_dir, assets_dir): + fonts_dir = os.path.join(assets_dir, "fonts") + main_symbol = _symbol(symbols, "FontGraphics") + main_raw = rom.bytes(main_symbol.bank, main_symbol.address, 128 * 8) + main = Image.new("RGBA", (128, 64), (0, 0, 0, 0)) + pixels = main.load() + for tile in range(128): + tile_x = (tile % 16) * 8 + tile_y = (tile // 16) * 8 + for y in range(8): + row = main_raw[tile * 8 + y] + for x in range(8): + if row & (1 << (7 - x)): + pixels[tile_x + x, tile_y + y] = (0, 0, 0, 255) + _save_png(main, os.path.join(fonts_dir, "font.png")) + + extra_symbol = _symbol(symbols, "TextBoxGraphics") + extra_raw = rom.bytes(extra_symbol.bank, extra_symbol.address, 32 * 16) + extra_shaded = _decode_2bpp(extra_raw, 128, 16) + extra = Image.new("RGBA", extra_shaded.size, (0, 0, 0, 0)) + source_pixels = extra_shaded.load() + target_pixels = extra.load() + for y in range(extra.height): + for x in range(extra.width): + if source_pixels[x, y][0] < 128: + target_pixels[x, y] = (0, 0, 0, 255) + + pokedex = _symbol(symbols, "PokedexTileGraphics") + dex_tiles = _decode_2bpp( + rom.bytes(pokedex.bank, pokedex.address, 32), 16, 8) + dex_pixels = dex_tiles.load() + for y in range(8): + for x in range(16): + extra.putpixel( + (x, y), + (0, 0, 0, 255) if dex_pixels[x, y][0] < 128 + else (0, 0, 0, 0)) + _save_png(extra, os.path.join(fonts_dir, "font_extra.png")) + + data = { + "source": "ROM:FontGraphics, TextBoxGraphics, PokedexTileGraphics", + "image": "assets/generated/fonts/font.png", + "imageExtra": "assets/generated/fonts/font_extra.png", + "mainBase": 0x80, + "extraBase": 0x60, + "glyphsPerRow": 16, + "charmap": manifest["fontCharmap"], + } + util.write_lua( + os.path.join(out_dir, "font.lua"), data, + header="Source: canonical Pokemon Red ROM font graphics;\n" + "symbolic charmap comes from the metadata manifest") + return data + + +def extract_sprites(rom, symbols, manifest, out_dir, assets_dir): + order = manifest["constants"]["spriteOrder"] + metadata = manifest["sprites"]["order"] + table = _symbol(symbols, "SpriteSheetPointerTable") + if len(metadata) != len(order): + raise ValueError("sprite metadata count does not match constants") + + out = {} + written = set() + for index, (const_name, spec) in enumerate(zip(order, metadata)): + if spec["id"] != const_name: + raise ValueError( + f"sprite metadata {spec['id']} is out of order at {const_name}") + address = table.address + index * 4 + pointer = rom.word(table.bank, address) + first_half_length = rom.byte(table.bank, address + 2) + bank = rom.byte(table.bank, address + 3) + byte_length = spec["imageWidth"] * spec["imageHeight"] // 4 + frames = spec["imageHeight"] // 16 + expected_length = first_half_length * (2 if frames >= 6 else 1) + if byte_length != expected_length: + raise ValueError( + f"{const_name}: ROM sprite length {expected_length} does not " + f"match atlas length {byte_length}") + + base = spec["imageBase"] + if base not in written: + _write_2bpp_png( + rom.bytes(bank, pointer, byte_length), + spec["imageWidth"], spec["imageHeight"], + os.path.join(assets_dir, "sprites", base + ".png"), + transparent_color0=True) + written.add(base) + out[const_name] = { + "id": const_name, + "source": f"ROM:SpriteSheetPointerTable[{index}]", + "image": f"assets/generated/sprites/{base}.png", + "frames": frames, + "walker": frames >= 6, + } + + bike = manifest["sprites"]["bike"] + bike_symbol = _symbol(symbols, bike["label"]) + bike_length = bike["imageWidth"] * bike["imageHeight"] // 4 + _write_2bpp_png( + rom.bytes(bike_symbol.bank, bike_symbol.address, bike_length), + bike["imageWidth"], bike["imageHeight"], + os.path.join(assets_dir, "sprites", bike["imageBase"] + ".png"), + transparent_color0=True) + bike_frames = bike["imageHeight"] // 16 + out["SPRITE_RED_BIKE"] = { + "id": "SPRITE_RED_BIKE", + "source": "ROM:RedBikeSprite", + "image": "assets/generated/sprites/red_bike.png", + "frames": bike_frames, + "walker": bike_frames >= 6, + } + + util.write_lua( + os.path.join(out_dir, "sprites.lua"), out, + header="Source: canonical Pokemon Red ROM (overworld sprite sheets)") + return out + + +def _signed_byte(value): + return value - 0x100 if value & 0x80 else value + + +def _map_id(order, value): + if value == 0xFF: + return "LAST_MAP" + if value >= len(order): + raise ValueError(f"unknown map id ${value:02X}") + return order[value] + + +def extract_maps(rom, symbols, manifest, out_dir): + map_order = manifest["constants"]["mapOrder"] + dimensions = manifest["constants"]["maps"] + metadata = manifest["maps"] + tilesets = manifest["constants"]["tilesetOrder"] + sprites = manifest["constants"]["spriteOrder"] + movement_names = {0xFE: "WALK", 0xFF: "STAY"} + range_names = { + 0x00: "ANY_DIR", + 0x01: "UP_DOWN", + 0x02: "LEFT_RIGHT", + 0x10: "BOULDER_MOVEMENT_BYTE_2", + 0xD0: "DOWN", + 0xD1: "UP", + 0xD2: "LEFT", + 0xD3: "RIGHT", + 0xFF: "NONE", + } + directions = ( + ("north", 0x08), + ("south", 0x04), + ("west", 0x02), + ("east", 0x01), + ) + + out = {} + for const_name, spec in metadata.items(): + dims = dimensions[const_name] + label = spec["label"] + header = _symbol(symbols, label + "_h") + address = header.address + tileset_id = rom.byte(header.bank, address) + height = rom.byte(header.bank, address + 1) + width = rom.byte(header.bank, address + 2) + if (width, height) != (dims["width"], dims["height"]): + raise ValueError( + f"{const_name}: ROM dimensions {width}x{height} do not match " + f"manifest {dims['width']}x{dims['height']}") + if tileset_id >= len(tilesets): + raise ValueError(f"{const_name}: unknown tileset id {tileset_id}") + block_pointer = rom.word(header.bank, address + 3) + connection_flags = rom.byte(header.bank, address + 9) + address += 10 + + connections = {} + for direction, bit in directions: + if not connection_flags & bit: + continue + target_id = rom.byte(header.bank, address) + y_offset = _signed_byte(rom.byte(header.bank, address + 7)) + x_offset = _signed_byte(rom.byte(header.bank, address + 8)) + encoded_offset = x_offset if direction in ("north", "south") \ + else y_offset + if encoded_offset % 2: + raise ValueError( + f"{const_name}: odd {direction} connection offset") + connections[direction] = { + "map": _map_id(map_order, target_id), + "offset": -encoded_offset // 2, + } + address += 11 + if connection_flags & ~0x0F: + raise ValueError( + f"{const_name}: unknown connection flags ${connection_flags:02X}") + object_pointer = rom.word(header.bank, address) + + object_address = object_pointer + border_block = rom.byte(header.bank, object_address) + object_address += 1 + + warp_count = rom.byte(header.bank, object_address) + object_address += 1 + warps = [] + for _ in range(warp_count): + y, x, dest_warp, dest_map = rom.bytes( + header.bank, object_address, 4) + warps.append({ + "x": x, + "y": y, + "destMap": _map_id(map_order, dest_map), + "destWarp": dest_warp + 1, + }) + object_address += 4 + + sign_count = rom.byte(header.bank, object_address) + object_address += 1 + if sign_count != len(spec["signTexts"]): + raise ValueError( + f"{const_name}: ROM has {sign_count} signs, metadata has " + f"{len(spec['signTexts'])}") + signs = [] + for sign_text in spec["signTexts"]: + y, x, _text_id = rom.bytes(header.bank, object_address, 3) + signs.append({"x": x, "y": y, "text": sign_text}) + object_address += 3 + + object_count = rom.byte(header.bank, object_address) + object_address += 1 + if object_count != len(spec["objects"]): + raise ValueError( + f"{const_name}: ROM has {object_count} objects, metadata has " + f"{len(spec['objects'])}") + objects = [] + for index, object_spec in enumerate(spec["objects"], start=1): + sprite_id, y, x, movement_id, range_id, text_id = rom.bytes( + header.bank, object_address, 6) + if not 1 <= sprite_id <= len(sprites): + raise ValueError( + f"{const_name} object {index}: unknown sprite {sprite_id}") + if movement_id not in movement_names or range_id not in range_names: + raise ValueError( + f"{const_name} object {index}: unknown movement encoding") + obj = { + "index": index, + "x": x - 4, + "y": y - 4, + "sprite": sprites[sprite_id - 1], + "movement": movement_names[movement_id], + "range": range_names[range_id], + "text": object_spec["text"], + } + object_address += 6 + + if text_id & 0x80: + if "item" not in object_spec: + raise ValueError( + f"{const_name} object {index}: unexpected item payload") + obj["item"] = object_spec["item"] + object_address += 1 + elif text_id & 0x40: + extra, level_or_party = rom.bytes( + header.bank, object_address, 2) + object_address += 2 + if "trainerClass" in object_spec: + obj["trainerClass"] = object_spec["trainerClass"] + party = object_spec.get("trainerParty") + obj["trainerParty"] = ( + party if isinstance(party, str) else level_or_party) + elif "pokemon" in object_spec: + obj["pokemon"] = object_spec["pokemon"] + obj["level"] = level_or_party + else: + raise ValueError( + f"{const_name} object {index}: unexpected trainer " + "or static Pokemon payload") + _ = extra + elif any(key in object_spec for key in ( + "item", "trainerClass", "pokemon")): + raise ValueError( + f"{const_name} object {index}: missing extra payload") + + for key in ("name", "hidden"): + if key in object_spec: + obj[key] = object_spec[key] + objects.append(obj) + + expected_blocks = width * height + block_length = spec["blockLength"] + if block_length > expected_blocks: + raise ValueError( + f"{const_name}: block payload is longer than map dimensions") + blocks = list(rom.bytes( + header.bank, block_pointer, block_length)) + blocks.extend([border_block] * (expected_blocks - block_length)) + + out[const_name] = { + "id": const_name, + "label": label, + "index": dims["index"], + "source": f"ROM:{header.bank:02X}:{header.address:04X}", + "tileset": tilesets[tileset_id], + "width": width, + "height": height, + "blocks": blocks, + "borderBlock": border_block, + "connections": connections, + "warps": warps, + "signs": signs, + "objects": objects, + } + + util.write_lua( + os.path.join(out_dir, "maps.lua"), out, + header="Source: canonical Pokemon Red ROM (map headers, block maps,\n" + "connections, warps, signs, and object events)") + return out + + +def _animation_flags(rom, symbols, count): + table = _symbol(symbols, "AttackAnimationPointers") + flags = [] + for index in range(count): + address = rom.word(table.bank, table.address + index * 2) + shake = False + flash = False + for _ in range(256): + first = rom.byte(table.bank, address) + if first == 0xFF: + break + if first >= 0xD8: + shake = shake or first == 0xFB + flash = flash or first in (0xF8, 0xFE) + address += 2 + else: + address += 3 + else: + raise ValueError(f"unterminated move animation {index + 1}") + flags.append((shake, flash)) + return flags + + +def extract_moves(rom, symbols, manifest, out_dir): + order = manifest["constants"]["moveOrder"] + type_by_id = { + int(value): name + for name, value in manifest["constants"]["types"].items() + } + effects = manifest["moveEffects"] + charmap = manifest["charmap"] + sfx_keys = manifest["sfxKeys"] + + moves = _symbol(symbols, "Moves") + names = _symbol(symbols, "MoveNames") + sounds = _symbol(symbols, "MoveSoundTable") + flags = _animation_flags(rom, symbols, len(order)) + + decoded_names = [] + address = names.address + for _ in order: + value, consumed = read_string( + rom, names.bank, address, charmap, max_length=32) + decoded_names.append(value) + address += consumed + + out = {} + for index, move_id in enumerate(order): + row = rom.bytes(moves.bank, moves.address + index * 6, 6) + if row[0] != index + 1: + raise ValueError( + f"Moves row {index + 1} stores animation id {row[0]}") + effect = effects[row[1]] if row[1] < len(effects) else f"EFFECT_{row[1]:02X}" + type_name = type_by_id.get(row[3], f"TYPE_{row[3]:02X}") + sound_id, pitch, tempo = rom.bytes( + sounds.bank, sounds.address + index * 3, 3) + anim = { + "sound": sfx_keys.get(str(sound_id), f"SFX_{sound_id:02X}"), + "pitch": pitch, + "tempo": tempo, + } + shake, flash = flags[index] + if shake: + anim["shake"] = True + if flash: + anim["flash"] = True + out[move_id] = { + "id": move_id, + "index": index + 1, + "name": decoded_names[index], + "source": f"ROM:Moves[{index + 1}]", + "effect": effect, + "power": row[2], + "type": type_name, + "accuracy": round(row[4] * 100 / 255), + "pp": row[5], + "anim": anim, + } + + util.write_lua( + os.path.join(out_dir, "moves.lua"), out, + header="Source: canonical Pokemon Red ROM (Moves, MoveNames,\n" + "MoveSoundTable, AttackAnimationPointers)") + return out + + +def extract_battle_anims( + rom, symbols, manifest, out_dir, assets_dir): + metadata = manifest["battleAnimations"] + move_order = manifest["constants"]["moveOrder"] + if len(move_order) != metadata["moveCount"]: + raise ValueError("battle animation move count does not match constants") + + coords_symbol = _symbol(symbols, "FrameBlockBaseCoords") + base_coords = {} + for index in range(metadata["baseCoordCount"]): + y, x = rom.bytes( + coords_symbol.bank, coords_symbol.address + index * 2, 2) + base_coords[index] = {"y": y, "x": x} + + blocks_symbol = _symbol(symbols, "FrameBlockPointers") + frame_blocks = {} + for index in range(metadata["frameBlockCount"]): + address = rom.word( + blocks_symbol.bank, blocks_symbol.address + index * 2) + count = rom.byte(blocks_symbol.bank, address) + address += 1 + entries = [] + for _ in range(count): + y, x, tile, attrs = rom.bytes( + blocks_symbol.bank, address, 4) + entry = { + "y": y, + "x": x, + "tile": tile, + "xflip": bool(attrs & 0x20), + "yflip": bool(attrs & 0x40), + } + if attrs & 0x80: + entry["prio"] = True + if attrs & 0x10: + entry["pal1"] = True + entries.append(entry) + address += 4 + frame_blocks[index] = entries + + subanim_symbol = _symbol(symbols, "SubanimationPointers") + subanims = {} + type_names = metadata["subanimTypes"] + for index in range(metadata["subanimCount"]): + address = rom.word( + subanim_symbol.bank, subanim_symbol.address + index * 2) + packed = rom.byte(subanim_symbol.bank, address) + type_id, count = packed >> 5, packed & 0x1F + if type_id >= len(type_names): + raise ValueError( + f"subanimation {index} has unknown type {type_id}") + address += 1 + entries = [] + for _ in range(count): + block, coord, mode = rom.bytes( + subanim_symbol.bank, address, 3) + if block >= metadata["frameBlockCount"]: + raise ValueError( + f"subanimation {index} has invalid frame block {block}") + if coord >= metadata["baseCoordCount"]: + raise ValueError( + f"subanimation {index} has invalid base coord {coord}") + entries.append({ + "block": block, "coord": coord, "mode": mode}) + address += 3 + subanims[index] = { + "type": type_names[type_id], + "blocks": entries, + } + + tiles_table = _symbol(symbols, "MoveAnimationTilesPointers") + tile_rows = [] + tile_specs = metadata["tilesheets"] + if len(tile_specs) != 3: + raise ValueError("expected three battle animation tilesheets") + for index, spec in enumerate(tile_specs): + count, low, high, padding = rom.bytes( + tiles_table.bank, tiles_table.address + index * 4, 4) + if padding != 0xFF: + raise ValueError( + f"battle animation tilesheet {index} has invalid padding") + pointer = low | high << 8 + expected = _symbol(symbols, f"MoveAnimationTiles{index}") + if expected.bank != tiles_table.bank or expected.address != pointer: + raise ValueError( + f"battle animation tilesheet {index} pointer differs") + tile_rows.append({ + "count": count, "pointer": pointer, "spec": spec}) + + image_payloads = {} + for row in tile_rows: + path = row["spec"]["path"] + existing = image_payloads.get(path) + if existing and existing["pointer"] != row["pointer"]: + raise ValueError( + f"shared battle animation atlas {path} has two pointers") + if not existing: + existing = {"pointer": row["pointer"], "tiles": 0, + "spec": row["spec"]} + image_payloads[path] = existing + existing["tiles"] = max(existing["tiles"], row["count"]) + + for path, payload in image_payloads.items(): + spec = payload["spec"] + byte_length = spec["width"] * spec["height"] // 4 + stored_length = payload["tiles"] * 16 + if stored_length > byte_length: + raise ValueError(f"{path}: battle animation atlas is too large") + raw = rom.bytes( + tiles_table.bank, payload["pointer"], stored_length) + raw += bytes(byte_length - stored_length) + prefix = "assets/generated/" + if not path.startswith(prefix): + raise ValueError(f"invalid generated asset path {path!r}") + _write_2bpp_png( + raw, spec["width"], spec["height"], + os.path.join(assets_dir, path[len(prefix):]), + transparent_color0=True) + + tilesheets = {} + for index, row in enumerate(tile_rows): + spec = row["spec"] + tilesheets[index] = { + "path": spec["path"], + "width": spec["width"], + "height": spec["height"], + "tiles": row["count"], + "source": spec["source"], + } + + move_names = move_order + metadata["miscAnimations"] + pointer_table = _symbol(symbols, "AttackAnimationPointers") + first_special = metadata["firstSpecialEffect"] + special_effects = metadata["specialEffects"] + move_anims = {} + for index, name in enumerate(move_names): + address = rom.word( + pointer_table.bank, pointer_table.address + index * 2) + sequence = [] + for _ in range(256): + first = rom.byte(pointer_table.bank, address) + if first == 0xFF: + break + sound = rom.byte(pointer_table.bank, address + 1) + if first >= first_special: + effect = special_effects.get(str(first)) + if not effect: + raise ValueError( + f"{name}: unknown special effect ${first:02X}") + row = {"effect": effect} + address += 2 + else: + subanim = rom.byte(pointer_table.bank, address + 2) + delay = first & 0x3F + tileset = first >> 6 + if not delay: + raise ValueError(f"{name}: zero animation delay") + if subanim >= metadata["subanimCount"]: + raise ValueError( + f"{name}: unknown subanimation {subanim}") + if tileset not in tilesheets: + raise ValueError( + f"{name}: unknown animation tileset {tileset}") + row = { + "subanim": subanim, + "tileset": tileset, + "delay": delay, + } + address += 3 + if sound != 0xFF: + if sound >= len(move_order): + raise ValueError( + f"{name}: unknown animation sound {sound}") + row["sound"] = move_order[sound] + sequence.append(row) + else: + raise ValueError(f"{name}: unterminated battle animation") + move_anims[name] = { + "source": f"ROM:AttackAnimationPointers[{index}]", + "seq": sequence, + } + + for name, anim in move_anims.items(): + for row in anim["seq"]: + if "subanim" not in row: + continue + sheet = tilesheets[row["tileset"]] + for block_ref in subanims[row["subanim"]]["blocks"]: + for tile in frame_blocks[block_ref["block"]]: + if tile["tile"] >= sheet["tiles"]: + raise ValueError( + f"{name}: tile {tile['tile']} is out of range " + f"for tileset {row['tileset']}") + + out = { + "tilesheets": tilesheets, + "baseCoords": base_coords, + "frameBlocks": frame_blocks, + "subanims": subanims, + "moveAnims": move_anims, + } + util.write_lua( + os.path.join(out_dir, "battle_anims.lua"), out, + header="Source: canonical Pokemon Red ROM battle animation tables,\n" + "frame geometry, coordinates, and OAM tile graphics") + return out + + +def _nybbles(raw, count): + out = [] + for value in raw: + out.extend((value >> 4, value & 0x0F)) + return out[:count] + + +def extract_items(rom, symbols, manifest, out_dir): + order = manifest["items"] + charmap = manifest["charmap"] + names = _symbol(symbols, "ItemNames") + prices = _symbol(symbols, "ItemPrices") + key_flags = _symbol(symbols, "KeyItemFlags") + tm_prices = _symbol(symbols, "TechnicalMachinePrices") + + decoded_names = [] + address = names.address + for _ in order: + value, consumed = read_string( + rom, names.bank, address, charmap, max_length=32) + decoded_names.append(value) + address += consumed + + num_items = manifest["numItems"] + flags = rom.bytes(key_flags.bank, key_flags.address, (num_items + 7) // 8) + out = {} + for index, item_id in enumerate(order): + entry = { + "id": item_id, + "index": index + 1, + "name": decoded_names[index], + "price": bcd(rom.bytes( + prices.bank, prices.address + index * 3, 3)), + "source": f"ROM:ItemNames[{index + 1}]", + } + if index < num_items and flags[index // 8] & (1 << (index % 8)): + entry["keyItem"] = True + out[item_id] = entry + + for number, move in enumerate(manifest["hms"], start=1): + item_id = "HM_" + move + out[item_id] = { + "id": item_id, + "name": f"HM{number:02d}", + "price": 0, + "machine": {"kind": "HM", "number": number, "move": move}, + "source": "ROM metadata manifest (HM mapping)", + } + + tms = manifest["tms"] + packed = rom.bytes( + tm_prices.bank, tm_prices.address, (len(tms) + 1) // 2) + prices_by_tm = _nybbles(packed, len(tms)) + for number, move in enumerate(tms, start=1): + item_id = "TM_" + move + out[item_id] = { + "id": item_id, + "name": f"TM{number:02d}", + "price": prices_by_tm[number - 1] * 1000, + "machine": {"kind": "TM", "number": number, "move": move}, + "source": f"ROM:TechnicalMachinePrices[{number}]", + } + + util.write_lua( + os.path.join(out_dir, "items.lua"), out, + header="Source: canonical Pokemon Red ROM (ItemNames, ItemPrices,\n" + "KeyItemFlags, TechnicalMachinePrices)") + return out + + +def extract_type_chart(rom, symbols, manifest, out_dir): + type_by_id = { + int(value): name + for name, value in manifest["constants"]["types"].items() + } + effects = _symbol(symbols, "TypeEffects") + address = effects.address + matchups = [] + while rom.byte(effects.bank, address) != 0xFF: + attacker, defender, multiplier = rom.bytes( + effects.bank, address, 3) + matchups.append({ + "attacker": type_by_id.get(attacker, f"TYPE_{attacker:02X}"), + "defender": type_by_id.get(defender, f"TYPE_{defender:02X}"), + "multiplier": multiplier, + }) + address += 3 + + names = [] + seen = set() + for label in manifest["typeNameLabels"]: + symbol = _symbol(symbols, label) + if (symbol.bank, symbol.address) in seen: + continue + seen.add((symbol.bank, symbol.address)) + name, _ = read_string( + rom, symbol.bank, symbol.address, manifest["charmap"], + max_length=16) + names.append(name) + + data = { + "source": "ROM:TypeEffects + TypeNames", + "matchups": matchups, + "names": names, + } + util.write_lua( + os.path.join(out_dir, "type_chart.lua"), data, + header="Source: canonical Pokemon Red ROM; multipliers are x10") + return data + + +def _scale5(value): + return round(value * 255 / 31) + + +def extract_palettes(rom, symbols, manifest, out_dir): + order = manifest["paletteOrder"] + table = _symbol(symbols, "SuperPalettes") + palettes = {} + for index, name in enumerate(order): + colors = [] + for color in range(4): + value = rom.word( + table.bank, table.address + index * 8 + color * 2) + colors.append([ + _scale5(value & 0x1F), + _scale5((value >> 5) & 0x1F), + _scale5((value >> 10) & 0x1F), + ]) + palettes[name] = colors + + mon_table = _symbol(symbols, "MonsterPalettes") + mon_pals = {} + for index, species in enumerate(manifest["dexOrder"], start=1): + palette_id = rom.byte(mon_table.bank, mon_table.address + index) + mon_pals[species] = order[palette_id] + + data = { + "source": "ROM:SuperPalettes + MonsterPalettes", + "palettes": palettes, + "order": order, + "pokemon": mon_pals, + } + util.write_lua( + os.path.join(out_dir, "palettes.lua"), data, + header="Source: canonical Pokemon Red ROM; 4 RGB colors per palette") + return data + + +def extract_icons(rom, symbols, manifest, out_dir, assets_dir): + table = _symbol(symbols, "MonPartyData") + count = len(manifest["dexOrder"]) + packed = rom.bytes(table.bank, table.address, (count + 1) // 2) + values = _nybbles(packed, count) + by_dex = [ + manifest["iconOrder"][value] + if value < len(manifest["iconOrder"]) else f"ICON_{value:X}" + for value in values + ] + icons = { + "MON": "assets/generated/sprites/monster.png", + "BALL": "assets/generated/sprites/poke_ball.png", + "HELIX": "assets/generated/sprites/fossil.png", + "FAIRY": "assets/generated/sprites/fairy.png", + "BIRD": "assets/generated/sprites/bird.png", + "WATER": "assets/generated/sprites/seel.png", + "BUG": "assets/generated/icons/bug.png", + "GRASS": "assets/generated/icons/plant.png", + "SNAKE": "assets/generated/icons/snake.png", + "QUADRUPED": "assets/generated/icons/quadruped.png", + } + icon_frames = { + "bug": ("BugIconFrame1", "BugIconFrame2"), + "plant": ("PlantIconFrame1", "PlantIconFrame2"), + "snake": ("SnakeIconFrame1", "SnakeIconFrame2"), + "quadruped": ("QuadrupedIconFrame1", "QuadrupedIconFrame2"), + } + for filename, labels in icon_frames.items(): + raw = bytearray() + for label in labels: + symbol = _symbol(symbols, label) + raw.extend(rom.bytes(symbol.bank, symbol.address, 32)) + half = _decode_2bpp(bytes(raw), 8, 32, transparent_color0=True) + image = Image.new("RGBA", (16, 32), (255, 255, 255, 0)) + for frame in range(2): + crop = half.crop((0, frame * 16, 8, frame * 16 + 16)) + image.paste(crop, (0, frame * 16)) + image.paste( + crop.transpose(Image.Transpose.FLIP_LEFT_RIGHT), + (8, frame * 16)) + _save_png( + image, os.path.join(assets_dir, "icons", filename + ".png")) + + data = { + "source": "ROM:MonPartyData", + "byDex": by_dex, + "icons": icons, + } + util.write_lua( + os.path.join(out_dir, "icons.lua"), data, + header="Source: canonical Pokemon Red ROM (MonPartyData)") + return data + + +def _species(manifest, value): + order = manifest["constants"]["speciesOrder"] + if not 1 <= value <= len(order): + return f"SPECIES_{value:02X}" + return order[value - 1] + + +def _item(manifest, value): + order = manifest["items"] + if not 1 <= value <= len(order): + return f"ITEM_{value:02X}" + return order[value - 1] + + +def _move(manifest, value): + order = manifest["constants"]["moveOrder"] + if value == 0: + return None + if not 1 <= value <= len(order): + return f"MOVE_{value:02X}" + return order[value - 1] + + +def _types_by_id(manifest): + return { + int(value): name + for name, value in manifest["constants"]["types"].items() + } + + +def _decode_evos_moves(rom, symbols, manifest, index): + table = _symbol(symbols, "EvosMovesPointerTable") + address = rom.word(table.bank, table.address + index * 2) + evolutions = [] + while True: + method = rom.byte(table.bank, address) + address += 1 + if method == 0: + break + if method == 1: + level, species = rom.bytes(table.bank, address, 2) + address += 2 + evolutions.append({ + "method": "LEVEL", + "level": level, + "species": _species(manifest, species), + }) + elif method == 2: + item, level, species = rom.bytes(table.bank, address, 3) + address += 3 + evolutions.append({ + "method": "ITEM", + "item": _item(manifest, item), + "level": level, + "species": _species(manifest, species), + }) + elif method == 3: + level, species = rom.bytes(table.bank, address, 2) + address += 2 + evolutions.append({ + "method": "TRADE", + "level": level, + "species": _species(manifest, species), + }) + else: + raise ValueError( + f"unknown evolution method {method} for species index {index + 1}") + + learnset = [] + while True: + level = rom.byte(table.bank, address) + address += 1 + if level == 0: + break + move = rom.byte(table.bank, address) + address += 1 + learnset.append({"level": level, "move": _move(manifest, move)}) + return evolutions, learnset + + +def _dex_entry(rom, symbols, manifest, index, species): + table = _symbol(symbols, "PokedexEntryPointers") + address = rom.word(table.bank, table.address + index * 2) + kind, consumed = read_string( + rom, table.bank, address, manifest["charmap"], max_length=32) + address += consumed + height_ft, height_in = rom.bytes(table.bank, address, 2) + weight = rom.word(table.bank, address + 2) + address += 4 + if rom.byte(table.bank, address) != 0x17: + raise ValueError( + f"dex entry {index + 1} has no TX_FAR command") + text_address = rom.word(table.bank, address + 1) + text_bank = rom.byte(table.bank, address + 3) + text_label = manifest["dexEntryLabels"].get(species) + if text_label is None: + text_label = f"_DexEntry_{text_bank:02X}_{text_address:04X}" + return { + "kind": kind, + "heightFt": height_ft, + "heightIn": height_in, + "weight": weight, + "text": text_label, + } + + +def extract_pokemon(rom, symbols, manifest, out_dir, assets_dir): + species_order = manifest["constants"]["speciesOrder"] + dex_order = manifest["dexOrder"] + dex_by_species = { + species: index for index, species in enumerate(dex_order, start=1) + } + type_by_id = _types_by_id(manifest) + names = _symbol(symbols, "MonsterNames") + base_stats = _symbol(symbols, "BaseStats") + mew_stats = _symbol(symbols, "MewBaseStats") + + decoded_names = [] + for index in range(len(species_order)): + raw = rom.bytes(names.bank, names.address + index * 10, 10) + decoded_names.append( + decode_text(raw, manifest["charmap"])) + + out = {} + written_front = set() + written_back = set() + for index, species in enumerate(species_order): + if species.startswith( + ("MISSINGNO", "UNUSED", "FOSSIL_", "MON_GHOST")): + continue + dex = dex_by_species[species] + if species == "MEW": + row = rom.bytes(mew_stats.bank, mew_stats.address, 28) + else: + row = rom.bytes( + base_stats.bank, base_stats.address + (dex - 1) * 28, 28) + if row[0] != dex: + raise ValueError( + f"{species} base stats store dex number {row[0]}, expected {dex}") + + level1_moves = [ + _move(manifest, value) for value in row[15:19] if value + ] + tmhm = [] + for bit, move in enumerate(manifest["tmhmMoves"]): + if row[20 + bit // 8] & (1 << (bit % 8)): + tmhm.append(move) + evolutions, learnset = _decode_evos_moves( + rom, symbols, manifest, index) + asset = manifest["pokemonAssets"][species] + front = asset["front"] + back = asset["back"] + if front and front not in written_front: + decoded_size = _write_compressed_pic( + rom, symbols, asset["frontLabel"], + os.path.join( + assets_dir, "battle", "front", front + ".png")) + if decoded_size != row[10] >> 4: + raise ValueError( + f"{species}: front picture size {decoded_size} does not " + f"match base stats {row[10] >> 4}") + written_front.add(front) + if back and back not in written_back: + _write_compressed_pic( + rom, symbols, asset["backLabel"], + os.path.join( + assets_dir, "battle", "back", back + ".png")) + written_back.add(back) + out[species] = { + "id": species, + "index": index + 1, + "dex": dex, + "name": decoded_names[index], + "source": f"ROM:BaseStats[{dex}]", + "types": list(dict.fromkeys( + type_by_id.get(value, f"TYPE_{value:02X}") + for value in row[6:8])), + "baseStats": { + "hp": row[1], + "attack": row[2], + "defense": row[3], + "speed": row[4], + "special": row[5], + }, + "catchRate": row[8], + "baseExp": row[9], + "level1Moves": level1_moves, + "growthRate": manifest["growthRates"][row[19]], + "tmhm": tmhm, + "learnset": learnset, + "evolutions": evolutions, + "spriteFront": ( + f"assets/generated/battle/front/{front}.png" + if front else None), + "spriteBack": ( + f"assets/generated/battle/back/{back}.png" + if back else None), + "frontSize": row[10] >> 4, + "dexEntry": _dex_entry( + rom, symbols, manifest, index, species), + } + + for label, filename in ( + ("FossilAerodactylPic", "fossilaerodactyl"), + ("FossilKabutopsPic", "fossilkabutops"), + ("GhostPic", "ghost")): + _write_compressed_pic( + rom, symbols, label, + os.path.join( + assets_dir, "battle", "front", filename + ".png")) + for label, filename in ( + ("RedPicBack", "redb"), + ("OldManPicBack", "oldmanb")): + _write_compressed_pic( + rom, symbols, label, + os.path.join(assets_dir, "battle", filename + ".png")) + + balls = _symbol(symbols, "PokeballTileGraphics") + _write_2bpp_png( + rom.bytes(balls.bank, balls.address, 64), 32, 8, + os.path.join(assets_dir, "battle", "balls.png"), + transparent_color0=True) + + trainer_card = ( + ("TrainerInfoTextBoxTileGraphics", "trainer_info.png", 24, 24, False), + ("GymLeaderFaceAndBadgeTileGraphics", "badges.png", 16, 256, True), + ("BadgeNumbersTileGraphics", "badge_numbers.png", 16, 32, True), + ("CircleTile", "circle_tile.png", 8, 8, True), + ) + for label, filename, width, height, transparent in trainer_card: + symbol = _symbol(symbols, label) + length = width * height // 4 + _write_2bpp_png( + rom.bytes(symbol.bank, symbol.address, length), width, height, + os.path.join(assets_dir, "trainer_card", filename), + transparent_color0=transparent) + _write_compressed_pic( + rom, symbols, "RedPicFront", + os.path.join(assets_dir, "trainer_card", "red.png")) + + util.write_lua( + os.path.join(out_dir, "pokemon.lua"), out, + header="Source: canonical Pokemon Red ROM (BaseStats, MonsterNames,\n" + "EvosMovesPointerTable, PokedexEntryPointers)") + return out + + +def _trainer_parties(rom, bank, start, end, manifest): + parties = [] + address = start + while address < end: + first = rom.byte(bank, address) + address += 1 + party = [] + if first == 0xFF: + while True: + level = rom.byte(bank, address) + address += 1 + if level == 0: + break + species = rom.byte(bank, address) + address += 1 + party.append({ + "level": level, + "species": _species(manifest, species), + }) + else: + level = first + while True: + species = rom.byte(bank, address) + address += 1 + if species == 0: + break + party.append({ + "level": level, + "species": _species(manifest, species), + }) + parties.append(party) + if address != end: + raise ValueError( + f"trainer party data overran {bank:02X}:{end:04X}") + return parties + + +def extract_trainers(rom, symbols, manifest, out_dir, assets_dir): + order = manifest["trainers"] + charmap = manifest["charmap"] + names = _symbol(symbols, "TrainerNames") + pointers = _symbol(symbols, "TrainerDataPointers") + money = _symbol(symbols, "TrainerPicAndMoneyPointers") + choices = _symbol(symbols, "TrainerClassMoveChoiceModifications") + + decoded_names = [] + address = names.address + for _ in order: + name, consumed = read_string( + rom, names.bank, address, charmap, max_length=32) + decoded_names.append(name) + address += consumed + + ai_mods = [] + address = choices.address + for _ in order: + mods = [] + while True: + value = rom.byte(choices.bank, address) + address += 1 + if value == 0: + break + mods.append(value) + ai_mods.append(mods) + + party_starts = [ + rom.word(pointers.bank, pointers.address + index * 2) + for index in range(len(order)) + ] + party_ends = party_starts[1:] + [_symbol(symbols, "TrainerAI").address] + + out = {} + written_pics = set() + for index, label in enumerate(order): + trainer_id = "OPP_" + label + raw_money = rom.bytes( + money.bank, money.address + index * 5 + 2, 3) + pic = manifest["trainerPics"][index] + if pic and pic["imageBase"] not in written_pics: + _write_compressed_pic( + rom, symbols, pic["label"], + os.path.join( + assets_dir, "battle", "trainers", + pic["imageBase"] + ".png")) + written_pics.add(pic["imageBase"]) + out[trainer_id] = { + "id": trainer_id, + "index": index + 1, + "name": decoded_names[index], + "source": "ROM:TrainerDataPointers", + "pic": pic["path"] if pic else None, + "baseMoney": bcd(raw_money) // 100, + "aiMods": ai_mods[index], + "parties": _trainer_parties( + rom, pointers.bank, party_starts[index], + party_ends[index], manifest), + } + + util.write_lua( + os.path.join(out_dir, "trainers.lua"), out, + header="Source: canonical Pokemon Red ROM (TrainerDataPointers,\n" + "TrainerNames, TrainerPicAndMoneyPointers)") + return out + + +def _wild_table(rom, bank, address, manifest): + grass_rate = rom.byte(bank, address) + address += 1 + grass = {"rate": grass_rate, "slots": []} + if grass_rate: + for _ in range(10): + level, species = rom.bytes(bank, address, 2) + grass["slots"].append({ + "level": level, + "species": _species(manifest, species), + }) + address += 2 + + water_rate = rom.byte(bank, address) + address += 1 + water = {"rate": water_rate, "slots": []} + if water_rate: + for _ in range(10): + level, species = rom.bytes(bank, address, 2) + water["slots"].append({ + "level": level, + "species": _species(manifest, species), + }) + address += 2 + return grass, water + + +def extract_encounters(rom, symbols, manifest, out_dir): + maps = manifest["constants"]["mapOrder"] + pointers = _symbol(symbols, "WildDataPointers") + nothing = _symbol(symbols, "NothingWildMons") + out = {} + for index, map_id in enumerate(maps): + address = rom.word( + pointers.bank, pointers.address + index * 2) + if address == nothing.address: + continue + grass, water = _wild_table( + rom, pointers.bank, address, manifest) + entry = {"source": f"ROM:{pointers.bank:02X}:{address:04X}"} + if grass["rate"] or grass["slots"]: + entry["grass"] = grass + if water["rate"] or water["slots"]: + entry["water"] = water + out[map_id] = entry + + util.write_lua( + os.path.join(out_dir, "encounters.lua"), out, + header="Source: canonical Pokemon Red ROM (WildDataPointers)") + return out + + +TEXT_GLYPH_OVERRIDES = { + 0x4B: "{_CONT}", + 0x4C: "{SCROLL}", + 0x6D: "{COLON}", + 0xF0: "¥", +} + + +def _text_glyph(value, charmap): + if value in TEXT_GLYPH_OVERRIDES: + return TEXT_GLYPH_OVERRIDES[value] + glyph = charmap.get(str(value), f"{{BYTE:{value:02X}}}") + if glyph.startswith("<") and glyph.endswith(">"): + return "{" + glyph[1:-1] + "}" + return glyph + + +def _decode_text_commands(rom, symbol, charmap, substitutions): + address = symbol.address + pending = deque(substitutions) + out = [] + for _ in range(4096): + command = rom.byte(symbol.bank, address) + address += 1 + if command == 0x50: + if pending: + raise ValueError( + f"{symbol.name}: unused dynamic text substitutions") + return "".join(out) + if command == 0: + while True: + value = rom.byte(symbol.bank, address) + address += 1 + if value == 0x50: + break + if value in (0x57, 0x58, 0x5F): + if pending: + raise ValueError( + f"{symbol.name}: unused dynamic text substitutions") + return "".join(out) + out.append(_text_glyph(value, charmap)) + continue + if command in (1, 2, 9): + if not pending: + raise ValueError( + f"{symbol.name}: missing substitution for command " + f"${command:02X}") + expected, token = pending.popleft() + if command != expected: + raise ValueError( + f"{symbol.name}: expected command ${expected:02X}, " + f"found ${command:02X}") + out.append(token) + address += 2 if command == 1 else 3 + continue + raise ValueError( + f"{symbol.name}: unsupported text command ${command:02X}") + raise ValueError(f"{symbol.name}: text command stream is too long") + + +def extract_text(rom, symbols, manifest, out_dir): + metadata = manifest["text"] + charmap = manifest["charmap"] + dynamic = metadata["dynamic"] + trainer_headers = { + map_label: { + int(index): header for index, header in headers.items() + } + for map_label, headers in metadata["trainerHeaders"].items() + } + texts = {} + for label in metadata["labels"]: + texts[label] = _decode_text_commands( + rom, _symbol(symbols, label), charmap, dynamic.get(label, [])) + + util.write_lua( + os.path.join(out_dir, "text.lua"), texts, + header="Source: canonical Pokemon Red ROM text command streams") + util.write_lua( + os.path.join(out_dir, "text_pointers.lua"), metadata["pointers"], + header="Map text integration metadata; dialogue is decoded from ROM") + util.write_lua( + os.path.join(out_dir, "trainer_headers.lua"), + trainer_headers, + header="Trainer integration metadata; dialogue is decoded from ROM") + return { + "texts": texts, + "pointers": metadata["pointers"], + "trainerHeaders": trainer_headers, + } + + +def extract_field(rom, symbols, manifest, out_dir, assets_dir): + def raw_2bpp( + label, width, height, relative, transparent=False, matte=False, + columns=False, stored_length=None): + expected = width * height // 4 + length = expected if stored_length is None else stored_length + symbol = _symbol(symbols, label) + raw = rom.bytes(symbol.bank, symbol.address, length) + if len(raw) < expected: + raw += bytes(expected - len(raw)) + if columns: + raw = _columns_to_rows(raw, width // 8, height // 8) + image = _decode_2bpp(raw, width, height, transparent) + if matte: + image = _matte_color0(image) + _save_png(image, os.path.join(assets_dir, relative)) + return image + + def raw_1bpp(label, width, height, relative, transparent=False): + symbol = _symbol(symbols, label) + raw = rom.bytes(symbol.bank, symbol.address, width * height // 8) + image = _decode_1bpp(raw, width, height, transparent) + _save_png(image, os.path.join(assets_dir, relative)) + return image + + raw_2bpp( + "PokemonLogoGraphics", 128, 56, "title/pokemon_logo.png") + raw_1bpp("Version_GFX", 80, 8, "title/red_version.png") + raw_2bpp( + "PlayerCharacterTitleGraphics", 40, 56, "title/player.png", + matte=True) + raw_2bpp( + "NintendoCopyrightLogoGraphics", 152, 8, + "title/copyright.png") + raw_2bpp( + "GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png") + + falling_star = raw_2bpp( + "FallingStar", 8, 8, "intro/falling_star.png", + transparent=True) + blink = Image.new("RGBA", falling_star.size, (255, 255, 255, 0)) + for y in range(falling_star.height): + for x in range(falling_star.width): + pixel = falling_star.getpixel((x, y)) + if pixel[3] and pixel[0] == 170: + blink.putpixel((x, y), pixel) + _save_png(blink, os.path.join( + assets_dir, "intro/falling_star_blink.png")) + + gamefreak = _symbol(symbols, "GameFreakIntro") + presents_raw = rom.bytes( + gamefreak.bank, gamefreak.address, 104 * 8 // 4) + presents = _decode_2bpp( + presents_raw, 104, 8, transparent_color0=True) + _save_png( + presents, os.path.join( + assets_dir, "intro/gamefreak_presents.png")) + logo_raw = rom.bytes( + gamefreak.bank, gamefreak.address + len(presents_raw), + 16 * 24 // 4) + _save_png( + _decode_2bpp(logo_raw, 16, 24, transparent_color0=True), + os.path.join(assets_dir, "intro/gamefreak_logo.png")) + + text_image = Image.new("RGBA", (80, 8), (255, 255, 255, 0)) + for index, tile in enumerate((0, 1, 2, 3, None, 4, 5, 3, 1, 6)): + if tile is not None: + text_image.paste( + presents.crop((tile * 8, 0, tile * 8 + 8, 8)), + (index * 8, 0)) + _save_png( + text_image, + os.path.join(assets_dir, "intro/gamefreak_text.png")) + + move_tiles = _symbol(symbols, "MoveAnimationTiles1") + star = Image.new("RGBA", (16, 16), (255, 255, 255, 0)) + for row, tile in ((0, 3), (1, 19)): + tile_raw = rom.bytes( + move_tiles.bank, move_tiles.address + tile * 16, 16) + image = _decode_2bpp( + tile_raw, 8, 8, transparent_color0=True) + star.paste(image, (0, row * 8)) + star.paste( + image.transpose(Image.Transpose.FLIP_LEFT_RIGHT), + (8, row * 8)) + _save_png(star, os.path.join(assets_dir, "intro/big_star.png")) + + gengar = _symbol(symbols, "FightIntroBackMon") + gengar_raw = rom.bytes(gengar.bank, gengar.address, 96 * 16) + gengar_tiles = [ + _decode_2bpp(gengar_raw[index:index + 16], 8, 8) + for index in range(0, len(gengar_raw), 16) + ] + for number in (1, 2, 3): + tilemap = _symbol(symbols, f"GengarIntroTiles{number}") + tile_ids = rom.bytes(tilemap.bank, tilemap.address, 49) + pose = Image.new("RGBA", (56, 56)) + for index, tile_id in enumerate(tile_ids): + pose.paste( + gengar_tiles[tile_id], + ((index % 7) * 8, (index // 7) * 8)) + pose = _matte_color0(pose) + _save_png( + pose, os.path.join( + assets_dir, "intro", f"gengar_{number}.png")) + + for number, label in enumerate(( + "FightIntroFrontMon", "FightIntroFrontMon2", + "FightIntroFrontMon3"), start=1): + raw_2bpp( + label, 48, 48, f"intro/red_nidorino_{number}.png", + transparent=True, columns=True) + + for number in (1, 2): + _write_compressed_pic( + rom, symbols, f"ShrinkPic{number}", + os.path.join(assets_dir, "intro", f"shrink{number}.png")) + + raw_2bpp( + "SlotMachineTiles1", 128, 24, "slots/red_slots_1.png", + stored_length=0x250) + slot_sheet = raw_2bpp( + "SlotMachineTiles2", 32, 48, "slots/red_slots_2.png") + transparent_slots = slot_sheet.copy() + for y in range(transparent_slots.height): + for x in range(transparent_slots.width): + if transparent_slots.getpixel((x, y)) == (255, 255, 255, 255): + transparent_slots.putpixel((x, y), (255, 255, 255, 0)) + slot_order = manifest["field"]["slotSymbols"]["order"] + symbol_sheet = Image.new( + "RGBA", (16 * len(slot_order), 16), (255, 255, 255, 0)) + for index, name in enumerate(slot_order): + value = manifest["field"]["slotSymbols"]["symbols"][name]["tiles"] + high, low = value >> 8, value & 0xFF + for row, tile in ((0, high), (1, low)): + x = (tile % 4) * 8 + y = (tile // 4) * 8 + symbol_sheet.paste( + transparent_slots.crop((x, y, x + 16, y + 8)), + (index * 16, row * 8)) + _save_png( + symbol_sheet, os.path.join(assets_dir, "slots/symbols.png")) + + emotes = Image.new("RGBA", (48, 16), (255, 255, 255, 0)) + for index, label in enumerate( + ("ShockEmote", "QuestionEmote", "HappyEmote")): + symbol = _symbol(symbols, label) + image = _decode_2bpp( + rom.bytes(symbol.bank, symbol.address, 64), 16, 16, + transparent_color0=True) + emotes.paste(image, (index * 16, 0)) + _save_png(emotes, os.path.join(assets_dir, "emotes.png")) + + raw_1bpp( + "LedgeHoppingShadow", 8, 8, "fx/shadow.png", + transparent=True) + for label, width, height, filename in ( + ("RedFishingRodTiles", 8, 24, "fishing_rod.png"), + ("RedFishingTilesSide", 16, 8, "red_fish_side.png"), + ("RedFishingTilesFront", 16, 8, "red_fish_front.png"), + ("RedFishingTilesBack", 16, 8, "red_fish_back.png"), + ("PokeCenterFlashingMonitorAndHealBall", 8, 16, + "heal_machine.png"), + ("SSAnneSmokePuffTile", 8, 8, "smoke.png")): + raw_2bpp( + label, width, height, "fx/" + filename, + transparent=True) + raw_2bpp( + "BattleTransitionTile", 8, 8, "fx/battle_transition.png") + raw_2bpp( + "PokedexTileGraphics", 24, 48, "fx/pokedex.png") + + raw_2bpp( + "HpBarAndStatusGraphics", 120, 16, + "battle/font_battle_extra.png", transparent=True) + for number, label in enumerate( + ("BattleHudTiles1", "BattleHudTiles2", "BattleHudTiles3"), + start=1): + raw_1bpp( + label, 24, 8, f"battle/battle_hud_{number}.png", + transparent=True) + + the_end = _symbol(symbols, "TheEndGfx") + interleaved = rom.bytes(the_end.bank, the_end.address, 160) + reordered = bytearray(160) + for column in range(5): + reordered[column * 16:(column + 1) * 16] = \ + interleaved[column * 32:column * 32 + 16] + reordered[(column + 5) * 16:(column + 6) * 16] = \ + interleaved[column * 32 + 16:column * 32 + 32] + _save_png( + _decode_2bpp(bytes(reordered), 40, 16), + os.path.join(assets_dir, "credits/the_end.png")) + + raw_2bpp( + "WorldMapTileGraphics", 32, 32, "townmap/tiles.png") + raw_1bpp( + "TownMapCursor", 16, 16, "townmap/cursor.png", + transparent=True) + + data = copy.deepcopy(manifest["field"]) + adjacency = data["hiddenExtras"]["trashCans"]["adjacent"] + data["hiddenExtras"]["trashCans"]["adjacent"] = { + int(index): values for index, values in adjacency.items() + } + data["source"] = "canonical Pokemon Red ROM + bundled port metadata" + util.write_lua( + os.path.join(out_dir, "field.lua"), data, + header="Field integration metadata; all referenced artwork is " + "decoded from ROM") + return data + + +def build(rom, symbols, manifest, out_dir, assets_dir, datasets): + results = {} + if "constants" in datasets: + results["constants"] = extract_constants(manifest, out_dir) + if "tilesets" in datasets: + results["tilesets"] = extract_tilesets( + rom, symbols, manifest, out_dir, assets_dir) + if "maps" in datasets: + results["maps"] = extract_maps( + rom, symbols, manifest, out_dir) + if "font" in datasets: + results["font"] = extract_font( + rom, symbols, manifest, out_dir, assets_dir) + if "sprites" in datasets: + results["sprites"] = extract_sprites( + rom, symbols, manifest, out_dir, assets_dir) + if "moves" in datasets: + results["moves"] = extract_moves( + rom, symbols, manifest, out_dir) + if "battle_anims" in datasets: + results["battle_anims"] = extract_battle_anims( + rom, symbols, manifest, out_dir, assets_dir) + if "items" in datasets: + results["items"] = extract_items( + rom, symbols, manifest, out_dir) + if "type_chart" in datasets: + results["type_chart"] = extract_type_chart( + rom, symbols, manifest, out_dir) + if "palettes" in datasets: + results["palettes"] = extract_palettes( + rom, symbols, manifest, out_dir) + if "icons" in datasets: + results["icons"] = extract_icons( + rom, symbols, manifest, out_dir, assets_dir) + if "pokemon" in datasets: + results["pokemon"] = extract_pokemon( + rom, symbols, manifest, out_dir, assets_dir) + if "trainers" in datasets: + results["trainers"] = extract_trainers( + rom, symbols, manifest, out_dir, assets_dir) + if "encounters" in datasets: + results["encounters"] = extract_encounters( + rom, symbols, manifest, out_dir) + if "text" in datasets: + results["text"] = extract_text( + rom, symbols, manifest, out_dir) + if "field" in datasets: + results["field"] = extract_field( + rom, symbols, manifest, out_dir, assets_dir) + return results + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rom", required=True, help="canonical Pokemon Red ROM") + parser.add_argument( + "--manifest", + default=os.path.join(os.path.dirname(__file__), "rom_manifest.json")) + parser.add_argument("--out", default="data/generated") + parser.add_argument("--assets", default="assets/generated") + parser.add_argument("--clean", action="store_true") + parser.add_argument( + "--only", action="append", choices=DATASETS, + help="build one dataset (repeatable); default builds all implemented") + args = parser.parse_args() + + try: + manifest = load_manifest(args.manifest) + rom = RomImage(args.rom, manifest["romSha1"]) + symbols = SymbolTable(manifest["symbols"]) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if args.clean: + for path in (args.out, args.assets): + if os.path.isdir(path): + shutil.rmtree(path) + os.makedirs(args.out, exist_ok=True) + os.makedirs(args.assets, exist_ok=True) + datasets = tuple(args.only) if args.only else DATASETS + try: + build(rom, symbols, manifest, args.out, args.assets, datasets) + except (ValueError, KeyError, IndexError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"\ndone: decoded {', '.join(datasets)} from ROM {rom.sha1}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/extract/__init__.py b/tools/extract/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/extract/audio.py b/tools/extract/audio.py new file mode 100644 index 00000000..f2f57911 --- /dev/null +++ b/tools/extract/audio.py @@ -0,0 +1,1173 @@ +"""Convert pret/pokered music into playable WAV files. + +Sources: + audio/headers/musicheaders*.asm -> song -> channel command streams + audio/music/*.asm -> the note/command data + audio/notes.asm -> pitch table + audio/wave_samples.asm -> channel-3 wave instruments + data/maps/songs.asm -> map -> song assignment + audio/engine_1.asm -> timing/frequency semantics (ported) + +Semantics ported from the sound engine (asm refs are audio/engine_1.asm): + note delay frames = length * speed * tempo / 0x100 (60 fps) + frequency register = (pitches[note] asr (octaveArg - 1)) & 0x7FF + square channels f = 131072 / (2048 - reg) + wave channel (ch3) f = 65536 / (2048 - reg) + envelope note_type volume/fade like NRx2 (step = fade/64s) + + vibrato delay, depth, rate (Audio1_vibrato l.384, apply l.88-142): + after `delay` frames from each note start the frequency register LOW + byte alternates between reg+above and reg-below where + above = depth/2 + depth%2, below = depth/2 (clamped to 0..$ff, high + byte untouched), toggling once every rate+1 frames. Approximations: + the engine's rate counter persists across notes; we restart it per + note (first toggle at frame delay+rate, then every rate+1 frames). + + pitch_slide length, octave, note (Audio1_pitch_slide l.432, + Audio1_InitPitchSlideVars l.1140, Audio1_ApplyPitchSlide l.1036): + the next note's frequency register ramps linearly from its own value + to the target note's register over (noteFrames - (length-1)) frames + (min 1) and then holds the target. The engine steps by + ceil(diff/duration) per frame (and has a borrow bug for increasing + slides); we use an exact linear ramp instead. + + duty_cycle_pattern a,b,c,d (Audio1_duty_cycle_pattern l.530, + Audio1_ApplyDutyCyclePattern l.1239): the pulse width cycles + a,b,c,d,a,... one step per 60 Hz frame. Approximation: the engine's + 2-bit rotation is aligned to the global frame counter; we align the + cycle to each note's start. + + wave instruments (Audio1_note_type l.328-368, + Audio1_ApplyWavePatternAndFrequency l.906, audio/wave_samples.asm): + on ch3 note_type's second byte is (volume << 4) | instrument; the + low nibble selects one of the 32x4-bit wave RAM instruments and + (volume & 3) maps to output level 0/100%/50%/25% like NR32. Waves + 0-4 are parsed from audio/wave_samples.asm; instruments 5-8 point at + garbage ("reads from sfx data") whose effective per-engine contents + are documented in that file's comments and hardcoded here (engine 1 + is used by Lavender Town, engine 3 by Pokemon Tower). The engine + bank (1/2/3) is taken from the header file the song came from. + + toggle_perfect_pitch (Audio1_toggle_perfect_pitch l.372, apply l.831): + while enabled every note's frequency register is incremented by 1. + + stereo_panning left, right (Audio1_stereo_panning l.505, + Audio1_EnableChannelOutput l.844): writes an NR51-style mask + (high nibble = left enables, low nibble = right enables, bit n = + channel n+1, default $ff). Songs are rendered as stereo WAVs; each + note event is placed left/right per the mask in effect at its start + time (the engine also re-applies at note starts). The mask is + global; pan events from any channel affect all channels. SFX and + cries stay mono (none of them pan). + + global tempo (Audio1_tempo l.476): wMusicTempo is shared by all four + music channels but the commands appear only on Ch1; every song is + interpreted with a shared tempo timeline built from a first pass + (periodically extended for tempo changes inside a loop body, e.g. + Dungeon3's accelerando). On sfx channels the engine overwrites + wSfxTempo on every note (Audio1_SetSfxTempo l.957), making tempo + commands in sfx data inert -- they are ignored, and a cry's tempo + modifier is not applied to its noise channel (CHAN8 skips + SetSfxTempo, l.714). + + seamless loops: channel time is tracked in exact integer ticks + (1/15360 s = 1/256 frame), so loop bodies and sample placement have + no float drift. A song with an infinite sound_loop is rendered as + two files, .wav (the intro: everything before the global loop + point) and _loop.wav (one loop body whose length is the exact + LCM of all channels' body lengths, so every channel realigns at the + seam). Per channel the loop starts at the loop target label if the + first traversal already matches the second, otherwise the first + traversal is absorbed into the intro (engine state carried into the + loop, verified event-by-event over three probe traversals). + One-shot channels (e.g. Dungeon3 drums) push the loop point past + their end. audio.lua song entries carry file/seconds/loopFile/ + loopSeconds and intro = true when a separate intro file exists; + zero-length intros collapse to a single seamlessly looping file. + If bodies cannot align (unsteady state, LCM beyond MAX_SECONDS, or + a loop length that is not a whole number of samples: 512 ticks = + 735 samples) the song falls back to the old single-file whole + render with a warning (Lavender, CinnabarMansion, Dungeon3 and + SilphCo fall back). + +Cry engine: PlayCry loads the species' pitch into wFrequencyModifier +(added to every frequency register write, Audio1_ApplyFrequencyModifier +l.978) and its length into wTempoModifier (sfx tempo = $80 + length, +Audio1_SetSfxTempo l.957). The frequency modifier is now also applied +to `note` commands (execute_music cries), not just square_note. + +Still approximated / not honored: + - noise channel: white-noise bursts with a fixed decay instead of the + GB LFSR noise instruments (drum_note instruments all sound alike) + - pitch_sweep (NR10 hardware sweep, SFX only) is ignored + - master `volume` command (always 7,7 in practice) is ignored + - vibrato is suppressed while a pitch slide is active (engine-correct) + but the engine's cross-note vibrato phase is not kept + - duty pattern rotation phase is per-note, not global + - envelopes restart per note event; hardware length counters ignored + +Determinism: no wall clock and no shared RNG state; noise bursts are +seeded per-event from (volume, sample count) so identical events render +identically (this also keeps loop bodies periodic). + +Output: + assets/generated/audio/music/.wav (22050 Hz stereo 16-bit) + assets/generated/audio/music/_loop.wav (loop body, if any) + assets/generated/audio/sfx/.wav (mono) + assets/generated/audio/cries/.wav (mono) + data/generated/audio.lua +""" + +import math +import os +import re +import wave + +import numpy as np + +from . import util +from .util import parse_number, read_asm, split_args, warn + +SAMPLE_RATE = 22050 +FRAME = 1.0 / 60.0 +MAX_SECONDS = 150 + +# All engine note durations are integer multiples of 1/256 frame +# (length * speed * tempo / 0x100 frames at 60 fps), so channel time is +# tracked in integer "ticks" of 1/15360 s. This keeps loop bodies and +# sample placement exact -- no float drift across loop iterations. +TICKS_PER_SECOND = 256 * 60 +FRAME_TICKS = 256 +MAX_TICKS = MAX_SECONDS * TICKS_PER_SECOND + + +def snap(ticks): + """Tick time -> sample index (round half up), in exact integer math: + samples = ticks * 22050 / 15360 = ticks * 735 / 512.""" + return (ticks * 735 + 256) // 512 + +PITCHES = [0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, + 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA] +NOTE_INDEX = {n: i for i, n in enumerate( + ["C_", "C#", "D_", "D#", "E_", "F_", "F#", "G_", "G#", "A_", "A#", "B_"])} + +DUTY = {0: 0.125, 1: 0.25, 2: 0.5, 3: 0.75} + +# NR32-style wave channel output level: (note_type volume & 3) +WAVE_LEVEL = {0: 0.0, 1: 1.0, 2: 0.5, 3: 0.25} + +# Effective contents of ".wave5" (instrument indexes 5-8) per audio +# engine bank; the pointers target sfx data, the values below are the +# ones documented in audio/wave_samples.asm's comments. +WAVE5 = { + 1: [2, 1, 14, 2, 3, 3, 2, 8, 14, 1, 2, 2, 15, 15, 14, 10, + 1, 0, 1, 4, 13, 12, 1, 0, 14, 3, 4, 1, 5, 1, 7, 3], + 2: [14, 12, 0, 2, 2, 0, 9, 1, 0, 7, 12, 0, 2, 0, 8, 1, + 0, 7, 13, 0, 2, 0, 9, 1, 0, 7, 12, 0, 2, 12, 10, 1], + 3: [2, 1, 14, 2, 3, 3, 2, 8, 14, 1, 2, 2, 15, 15, 2, 2, + 15, 7, 2, 4, 2, 2, 15, 7, 3, 4, 2, 4, 15, 7, 4, 4], +} + + +def parse_wave_instruments(pokered): + """audio/wave_samples.asm -> bank (1..3) -> list of 9 waveforms, + each a float32 array of 32 samples in [-1, 1].""" + base_waves = [] + path = os.path.join(pokered, "audio/wave_samples.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"dn\s+(.*)$", s) + if m: + vals = [parse_number(v) for v in split_args(m.group(1))] + if len(vals) == 32: + base_waves.append(vals) + if len(base_waves) < 5: + warn("audio: wave_samples.asm: expected 5 wave instruments, " + f"got {len(base_waves)}") + while len(base_waves) < 5: + base_waves.append(list(range(0, 16)) + list(range(15, -1, -1))) + + def to_signal(nibbles): + return np.array([(v - 7.5) / 7.5 for v in nibbles], dtype=np.float32) + + banks = {} + for bank in (1, 2, 3): + waves = [to_signal(w) for w in base_waves[:5]] + waves += [to_signal(WAVE5[bank])] * 4 # indexes 5-8 all alias wave5 + banks[bank] = waves + return banks + + +def parse_headers(pokered, prefix="musicheaders", label_prefix="Music_"): + """Music_X / SFX_X -> (ordered channel labels, bank number).""" + songs = {} + banks = {} + hdr_dir = os.path.join(pokered, "audio/headers") + for fname in sorted(os.listdir(hdr_dir)): + if not fname.startswith(prefix): + continue + m = re.search(r"(\d+)\.asm$", fname) + bank = int(m.group(1)) if m else 1 + current = None + for lineno, line in read_asm(os.path.join(hdr_dir, fname)): + s = line.strip() + m = re.match(rf"({label_prefix}\w+)::?\s*$", s) + if m: + current = m.group(1) + songs[current] = [] + banks[current] = bank + continue + m = re.match(r"channel\s+(\d+),\s*(\w+)", s) + if m and current: + songs[current].append((int(m.group(1)), m.group(2))) + return songs, banks + + +def parse_music_files_dir(path_or_file): + """Parse one asm file of audio command streams.""" + streams = {} + current = None + for lineno, line in read_asm(path_or_file): + s = line.strip() + if not s: + continue + m = re.match(r"(\w+)::?\s*$", s) + if m: + current = m.group(1) + streams[current] = [] + continue + m = re.match(r"\.(\w+):?\s*$", s) + if m and current: + streams[current].append(("label", f"{current}.{m.group(1)}")) + continue + m = re.match(r"(\w+)(?:\s+(.*))?$", s) + if m and current: + args = [a.strip() for a in split_args(m.group(2) or "") if a.strip()] + streams[current].append((m.group(1), args)) + return streams + + +def parse_music_files(pokered): + """All command streams: label -> list of (cmd, args); local .labels are + stored as ("label", "GlobalLabel.local").""" + streams = {} # label -> command list (with label markers inline) + music_dir = os.path.join(pokered, "audio/music") + for fname in sorted(os.listdir(music_dir)): + if not fname.endswith(".asm"): + continue + streams.update(parse_music_files_dir(os.path.join(music_dir, fname))) + return streams + + +def signed_nibble(raw): + """Assemble a fade/instrument arg the way the macros do: negative n + becomes %1000 | -n (signed magnitude).""" + return (0b1000 | -raw) if raw < 0 else raw + + +class Channel: + """Interprets one channel's command stream into note events. + + All times/durations are integer ticks (1/15360 s). freq_offset / + frame_ticks implement the cry engine: PlayCry loads the species' + pitch into wFrequencyModifier (added to every frequency register, + Audio1_ApplyFrequencyModifier) and its length into wTempoModifier + (sfx tempo = $80 + length ticks per frame, Audio1_SetSfxTempo). + """ + + def __init__(self, streams, label, is_wave, is_noise, + freq_offset=0, frame_ticks=FRAME_TICKS, + tempo_timeline=None, is_sfx=False): + self.streams = streams + self.label = label + self.is_wave = is_wave + self.is_noise = is_noise + self.freq_offset = freq_offset + # ticks per sfx frame: 256 nominally, 0x80 + cry length for cries + self.frame_ticks = frame_ticks + # wMusicTempo is global: usually only Ch1 issues tempo commands + # but they drive every music channel. A channel run with a + # tempo_timeline follows it; one run without records its own + # tempo commands into tempo_events so the timeline can be built. + self.tempo_timeline = tempo_timeline + self.tempo_events = [] + self._tempo_idx = 0 + # the engine resets wSfxTempo on every sfx-channel note + # (Audio1_SetSfxTempo), so tempo commands in sfx data are inert + self.is_sfx = is_sfx + self.events = [] # list of event dicts (see emit_tone) + self.time = 0 # integer ticks (1/15360 s) + self.tempo = 0x100 + self.speed = 12 + self.volume = 12 + self.fade = 0 + self.duty = 0.5 # float, or 4-tuple for duty_cycle_pattern + self.octave = 4 + self.wave_inst = 0 # ch3 instrument (note_type low nibble) + self.wave_level = 1.0 # ch3 output level (note_type volume & 3) + self.perfect_pitch = False + self.vib = None # (delay_frames, above, below, rate) + self.pending_slide = None # (length_arg, target_reg) + self.pan_events = [] # (time, left_mask, right_mask) + self.label_times = {} # label -> time of first crossing + self.loop_time = None # time when the infinite sound_loop runs + self.loop_start_time = None # first crossing of its target label + self.infinite_times = [] # time at each infinite sound_loop hit + + def note_ticks(self, length): + tl = self.tempo_timeline + if tl: + while self._tempo_idx < len(tl) \ + and tl[self._tempo_idx][0] <= self.time: + self.tempo = tl[self._tempo_idx][1] + self._tempo_idx += 1 + # length * speed * tempo / 256 frames = length * speed * tempo ticks + return length * self.speed * self.tempo + + def emit_tone(self, dur, reg, vol=None, fade=None): + ev = { + "t": self.time, "dur": dur, "reg": reg, + "vol": self.volume if vol is None else vol, + "fade": self.fade if fade is None else fade, + "duty": self.duty, + "vib": None if self.pending_slide else self.vib, + "slide": None, + "wave_inst": self.wave_inst, + "wave_level": self.wave_level, + } + if self.pending_slide: + length_arg, target = self.pending_slide + note_frames = dur / FRAME_TICKS + slide_frames = max(1.0, note_frames - (length_arg - 1)) + ev["slide"] = (target, slide_frames) + self.pending_slide = None + self.events.append(ev) + + def emit_noise(self, dur, vol): + self.events.append({"t": self.time, "dur": dur, "reg": None, + "vol": vol, "fade": 2, "duty": None, + "vib": None, "slide": None, + "wave_inst": 0, "wave_level": 0.0}) + + def run(self, target_time=None, iterations=2, max_ticks=MAX_TICKS): + """Interpret until the infinite loop completes `iterations` times + (or the stream ends). If target_time (ticks) is set, keep + looping the body until reaching it instead.""" + prog, labels = self.flatten() + pc = 0 + call_stack = [] + loop_counts = {} + infinite_seen = 0 + last_loop_time = -1 + guard = 0 + while pc < len(prog) and self.time < max_ticks: + guard += 1 + if guard > 5_000_000: + warn(f"audio: {self.label}: runaway stream") + break + cmd, args = prog[pc] + pc += 1 + if cmd == "label": + self.label_times.setdefault(args, self.time) + continue + if cmd == "square_note": + # sfx: length (frames-1), volume, fade, raw frequency register + dur = (parse_number(args[0]) + 1) * self.frame_ticks + reg = (parse_number(args[3]) + self.freq_offset) & 0x7FF + self.emit_tone(dur, reg, vol=parse_number(args[1]), + fade=parse_number(args[2])) + self.time += dur + continue + if cmd == "noise_note": + dur = (parse_number(args[0]) + 1) * self.frame_ticks + self.emit_noise(dur, parse_number(args[1])) + self.time += dur + continue + if cmd == "tempo": + if self.is_sfx or self.tempo_timeline is not None: + pass # inert on sfx; music follows the shared timeline + else: + self.tempo = parse_number(args[0]) + self.tempo_events.append((self.time, self.tempo)) + elif cmd == "note_type": + self.speed = parse_number(args[0]) + if self.is_wave: + # ch3: volume & 3 -> NR32 level, low nibble -> instrument + self.wave_level = WAVE_LEVEL[parse_number(args[1]) & 3] + self.wave_inst = signed_nibble(parse_number(args[2])) & 0xF + else: + self.volume = parse_number(args[1]) + self.fade = parse_number(args[2]) + elif cmd == "drum_speed": + self.speed = parse_number(args[0]) + elif cmd == "octave": + self.octave = parse_number(args[0]) + elif cmd == "duty_cycle": + self.duty = DUTY.get(parse_number(args[0]), 0.5) + elif cmd == "duty_cycle_pattern": + self.duty = tuple(DUTY.get(parse_number(a) & 3, 0.5) + for a in args[:4]) + elif cmd == "vibrato": + delay = parse_number(args[0]) + depth = parse_number(args[1]) & 0xF + rate = parse_number(args[2]) & 0xF + if depth == 0: + self.vib = None + else: + above = (depth >> 1) + (depth & 1) + below = depth >> 1 + self.vib = (delay, above, below, rate) + elif cmd == "pitch_slide": + # pitch_slide length, octave, note -> applies to next note + length_arg = parse_number(args[0]) + idx = NOTE_INDEX.get(args[2]) + if idx is not None: + target = self.freq_reg(idx, octave=parse_number(args[1])) + self.pending_slide = (max(1, length_arg), target) + elif cmd == "toggle_perfect_pitch": + self.perfect_pitch = not self.perfect_pitch + elif cmd == "stereo_panning": + self.pan_events.append((self.time, + parse_number(args[0]) & 0xF, + parse_number(args[1]) & 0xF)) + elif cmd == "note": + dur = self.note_ticks(parse_number(args[1])) + idx = NOTE_INDEX.get(args[0]) + if idx is not None and not self.is_noise: + self.emit_tone(dur, self.freq_reg(idx)) + elif self.is_noise: + self.emit_noise(dur, self.volume) + self.time += dur + elif cmd == "drum_note": + dur = self.note_ticks(parse_number(args[1])) + self.emit_noise(dur, 13) + self.time += dur + elif cmd == "rest": + self.time += self.note_ticks(parse_number(args[0])) + elif cmd == "sound_loop": + count = parse_number(args[0]) + target = args[1] + if count == 0: + if self.loop_time is None: + self.loop_time = self.time + self.loop_start_time = self.label_times.get(target) + self.infinite_times.append(self.time) + infinite_seen += 1 + progressing = self.time > last_loop_time + last_loop_time = self.time + if target_time is not None: + if self.time < target_time and progressing \ + and target in labels: + pc = labels[target] + # else: fall through (stop) + elif infinite_seen < iterations and progressing \ + and target in labels: + pc = labels[target] + # else: fall through (stop) + else: + key = pc + loop_counts.setdefault(key, count) + loop_counts[key] -= 1 + if loop_counts[key] > 0: + pc = labels[target] + else: + del loop_counts[key] + elif cmd == "sound_call": + call_stack.append(pc) + pc = labels[args[0]] + elif cmd == "sound_ret": + if call_stack: + pc = call_stack.pop() + else: + break + elif cmd in ("volume", "execute_music", "pitch_sweep", + "sfx_note", "unknownsfx0x20", "set_instrument", + "unknownmusic0xef"): + pass # unsupported nuances; documented in module docstring + else: + warn(f"audio: {self.label}: unhandled command {cmd}") + return self.events + + def freq_reg(self, note_idx, octave=None): + pitch = PITCHES[note_idx] + # arithmetic shift right (oct - 1) times on a signed 16-bit value + val = pitch - 0x10000 # negative + shifts = max(0, (self.octave if octave is None else octave) - 1) + val >>= shifts # python >> on negative = arithmetic + reg = val & 0x7FF + if self.perfect_pitch: + reg = (reg + 1) & 0x7FF + # cry frequency modifier (Audio1_ApplyFrequencyModifier) + return (reg + self.freq_offset) & 0x7FF + + def flatten(self): + """Inline program: own stream; labels map name -> pc. sound_call / + sound_loop targets may live in other global labels of the file.""" + prog = [] + labels = {} + seen = set() + + def add_stream(name): + if name in seen or name not in self.streams: + return + seen.add(name) + labels[name] = len(prog) + prog.append(("label", name)) + for cmd, args in self.streams[name]: + if cmd == "label": + labels[args] = len(prog) + prog.append(("label", args)) + else: + prog.append((cmd, args)) + prog.append(("sound_ret", [])) + + add_stream(self.label) + # add any referenced global labels (subroutine sharing) + changed = True + while changed: + changed = False + for cmd, args in list(prog): + if cmd in ("sound_call", "sound_loop"): + target = args[-1] + base = target.split(".")[0] + if not target.startswith(".") and base not in seen \ + and base in self.streams: + add_stream(base) + changed = True + # fix references: sound_loop/sound_call args ".x" -> "Label.x" + fixed = [] + cur_global = self.label + for cmd, args in prog: + if cmd == "label" and "." not in args: + cur_global = args + if cmd in ("sound_call", "sound_loop"): + args = list(args) + if args[-1].startswith("."): + args[-1] = f"{cur_global}{args[-1]}" + fixed.append((cmd, args)) + else: + fixed.append((cmd, args)) + return fixed, labels + + +def _tone_signal(ev, is_wave, waves, count): + """Per-sample signal for one tone event (before envelope/level).""" + base = ev["reg"] + duty = ev["duty"] + tt = np.arange(count, dtype=np.float64) / SAMPLE_RATE + need_frames = (ev["vib"] is not None or ev["slide"] is not None + or isinstance(duty, tuple)) + frames = None + if need_frames: + frames = np.floor(tt * 60.0).astype(np.int64) + reg = np.full(count, float(base)) + if ev["slide"] is not None: + target, slide_frames = ev["slide"] + reg = base + (target - base) * np.minimum( + 1.0, frames / max(1.0, slide_frames)) + elif ev["vib"] is not None: + delay, above, below, rate = ev["vib"] + lo = base & 0xFF + hi = base & 0x700 + up = hi | min(0xFF, lo + above) + dn = hi | max(0, lo - below) + toggles = np.maximum(0, frames - delay + 1) // (rate + 1) + reg = np.where(toggles == 0, float(base), + np.where(toggles % 2 == 1, float(up), float(dn))) + freq = 131072.0 / (2048.0 - np.minimum(reg, 2047.0)) + if is_wave: + freq *= 0.5 + phase = np.cumsum(freq) / SAMPLE_RATE + phase -= phase[0] # start at phase 0 like the constant path + else: + freq = 131072.0 / (2048 - base) + if is_wave: + freq *= 0.5 + phase = tt * freq + pfrac = phase % 1.0 + if is_wave: + wf = waves[min(ev["wave_inst"], len(waves) - 1)] if waves else None + if wf is None: # no wave table: triangle fallback + sig = (2.0 * np.abs(2.0 * pfrac - 1.0) - 1.0) + else: + idx = np.minimum((pfrac * 32).astype(np.int64), 31) + sig = wf[idx].astype(np.float64) + else: + if isinstance(duty, tuple): + dc = np.asarray(duty, dtype=np.float64)[frames % 4] + else: + dc = duty + sig = np.where(pfrac < dc, 1.0, -1.0) + return sig.astype(np.float32), tt.astype(np.float32) + + +def synthesize(events, is_wave, is_noise, total_ticks, + waves=None, pans=None): + """Render events to a float32 buffer (times/durations in ticks). + + pans: optional list of (gainL, gainR) parallel to events; when given + the result is (n, 2) stereo, otherwise (n,) mono. + """ + n = snap(total_ticks) + stereo = pans is not None + out = np.zeros((n, 2) if stereo else n, dtype=np.float32) + + def add(start, count, sig, gains): + if stereo: + gl, gr = gains + if gl: + out[start:start + count, 0] += sig * gl + if gr: + out[start:start + count, 1] += sig * gr + else: + out[start:start + count] += sig + + for i, ev in enumerate(events): + t, dur, reg, vol = ev["t"], ev["dur"], ev["reg"], ev["vol"] + start = snap(t) + count = snap(t + dur) - start # events butt with no gap/overlap + if start >= n or count <= 0: + continue + count = min(count, n - start) + gains = pans[i] if stereo else None + if reg is None: # noise burst (per-event deterministic seed) + rng = np.random.default_rng(0x9E3779B1 ^ (vol * 1000003 + count)) + tt = np.arange(count, dtype=np.float32) / SAMPLE_RATE + sig = rng.uniform(-1, 1, count).astype(np.float32) + dur_s = dur / TICKS_PER_SECOND + env = np.maximum(0.0, 1.0 - tt / max(1e-3, min(dur_s, 0.18))) + add(start, count, 0.35 * sig * env * (vol / 15.0), gains) + continue + if reg >= 2048: + continue + sig, tt = _tone_signal(ev, is_wave, waves, count) + if is_wave: + add(start, count, 0.55 * ev["wave_level"] * sig, gains) + continue + # NRx2-style envelope: volume steps down (or up) every fade/64 s + fade = ev["fade"] + env = np.full(count, vol / 15.0, dtype=np.float32) + if fade and fade > 0: + step = fade / 64.0 + env = np.maximum(0.0, (vol - np.floor(tt / step)) / 15.0) \ + .astype(np.float32) + elif fade and fade < 0: + step = -fade / 64.0 + env = (np.minimum(15.0, (vol + np.floor(tt / step))) + .astype(np.float32) / 15.0) + add(start, count, 0.5 * sig * env, gains) + return out + + +def write_wav(path, data): + """data: (n,) mono or (n, 2) stereo float32 in [-1, 1].""" + os.makedirs(os.path.dirname(path), exist_ok=True) + peak = np.max(np.abs(data)) if data.size else 1.0 + if peak > 1.0: + data = data / peak + pcm = (data * 32000).astype(np.int16) + with wave.open(path, "wb") as f: + f.setnchannels(2 if pcm.ndim == 2 else 1) + f.setsampwidth(2) + f.setframerate(SAMPLE_RATE) + f.writeframes(pcm.tobytes()) + + +def pan_gains(pan_timeline, t, chan_num): + """Left/right gains for music channel chan_num (1-4) at tick time t.""" + left, right = 0xF, 0xF + for pt, pl, pr in pan_timeline: + if pt <= t: + left, right = pl, pr + else: + break + bit = 1 << (chan_num - 1) + return (1.0 if left & bit else 0.0, 1.0 if right & bit else 0.0) + + +def _events_periodic(p, start, body): + """True if the two consecutive body traversals at [start, start+body) + and [start+body, start+2*body) produced identical events (channel + state carried across the loop seam is steady).""" + t1 = start + body + if p.time < start + 2 * body: + return False # probe did not run far enough to verify + ev1 = [e for e in p.events if start <= e["t"] < t1] + ev2 = [e for e in p.events if t1 <= e["t"] < t1 + body] + if len(ev1) != len(ev2): + return False + for a, b in zip(ev1, ev2): + if b["t"] - body != a["t"] or b["dur"] != a["dur"]: + return False + for k in ("reg", "vol", "fade", "duty", "vib", "slide", + "wave_inst", "wave_level"): + if a[k] != b[k]: + return False + return True + + +def plan_song_loop(song, probes): + """Decide the intro/loop split for a song. + + probes: list of (num, label, Channel) after a plain run(). + Returns (intro_seconds, loop_seconds) or None for a single-file + render. See the module docstring for the alignment rules. + """ + loopers = [(n, l, p) for n, l, p in probes if p.loop_time is not None] + if not loopers: + return None + intro = 0 + bodies = [] + for num, label, p in loopers: + if p.loop_start_time is None: + warn(f"audio: {song}: loop target of {label} never crossed; " + "single-file fallback") + return None + body = p.loop_time - p.loop_start_time + if body <= 0: + continue # channel just holds at its loop point + if _events_periodic(p, p.loop_start_time, body): + start = p.loop_start_time # steady from the loop label itself + elif _events_periodic(p, p.loop_time, body): + # first pass differs (state carried into the loop); absorb it + # into the intro and loop from the second pass onward + start = p.loop_time + else: + warn(f"audio: {song}: {label} loop body not steady; " + "single-file fallback") + return None + intro = max(intro, start) + bodies.append(body) + if not bodies: + return None + for num, label, p in probes: + # one-shot channels (e.g. Dungeon3's drums) simply end; the loop + # begins once they have finished + if p.loop_time is None: + intro = max(intro, p.time) + # align all channel bodies (exact integer tick LCM) + loop_ticks = bodies[0] + for b in bodies[1:]: + loop_ticks = loop_ticks * b // math.gcd(loop_ticks, b) + # the loop body must span a whole number of samples so it repeats on + # the sample grid: samples = ticks * 735 / 512, so 512 | loop_ticks + while loop_ticks % 512 and intro + 2 * loop_ticks <= MAX_TICKS: + loop_ticks *= 2 + if loop_ticks % 512: + warn(f"audio: {song}: loop body does not fit the sample grid; " + "single-file fallback") + return None + if intro + loop_ticks > MAX_TICKS: + warn(f"audio: {song}: loop bodies only align after " + f"{loop_ticks / TICKS_PER_SECOND:.1f}s; single-file fallback") + return None + return intro, loop_ticks + + +def build_tempo_timeline(channels_pass1): + """Shared wMusicTempo timeline from every channel's standalone run. + + Tempo commands inside a loop body (e.g. Dungeon3's accelerando) are + re-issued each traversal by the engine, so those entries are + replicated periodically out to the probe horizon.""" + events = [] + horizon = 3 * MAX_TICKS + 1 + for p in channels_pass1: + evs = p.tempo_events + if p.loop_time is not None: + evs = [(t, v) for t, v in evs if t < p.loop_time] + events.extend(evs) + if p.loop_time is not None and p.loop_start_time is not None: + body = p.loop_time - p.loop_start_time + inside = [(t, v) for t, v in evs if t >= p.loop_start_time] + if body > 0 and inside: + k = 1 + while p.loop_start_time + k * body < horizon: + events.extend((t + k * body, v) for t, v in inside) + k += 1 + return sorted(events) + + +def render_song(song, channels, streams, waves, assets_dir): + """Render one song; returns its audio.lua entry or None.""" + channels = [(num, label) for num, label in channels if label in streams + or warn(f"audio: {song}: missing channel stream {label}")] + if not channels: + return None + # pass 0: standalone runs to harvest the shared tempo timeline + pass1 = [] + for num, label in channels: + p = Channel(streams, label, is_wave=(num == 3), is_noise=(num == 4)) + p.run(iterations=2, max_ticks=3 * MAX_TICKS) + pass1.append(p) + timeline = build_tempo_timeline(pass1) + + probes = [] + for num, label in channels: + p = Channel(streams, label, is_wave=(num == 3), is_noise=(num == 4), + tempo_timeline=timeline) + # measure intro + three loop-body traversals (three are needed to + # verify periodicity even when the first pass carries state in) + p.run(iterations=3, max_ticks=3 * MAX_TICKS) + probes.append((num, label, p)) + if not probes or max(p.time for _, _, p in probes) <= TICKS_PER_SECOND // 20: + return None + + split = plan_song_loop(song, probes) + if split: + intro_ticks, loop_ticks = split + total = intro_ticks + loop_ticks + else: + # single-file fallback: intro + two body traversals, like before + ends = [p.infinite_times[1] if len(p.infinite_times) > 1 else p.time + for _, _, p in probes] + total = min(max(ends), MAX_TICKS) + + # render every channel out to the common end time (channels with + # shorter loop bodies keep repeating) + chans = [] + for num, label, _ in probes: + ch = Channel(streams, label, is_wave=(num == 3), is_noise=(num == 4), + tempo_timeline=timeline) + ch.run(target_time=total + FRAME_TICKS, + max_ticks=total + FRAME_TICKS) + chans.append((num, ch)) + pan_timeline = sorted( + (e for _, ch in chans for e in ch.pan_events), key=lambda e: e[0]) + n_total = snap(total) + mix = np.zeros((n_total, 2), dtype=np.float32) + for num, ch in chans: + pans = [pan_gains(pan_timeline, ev["t"], num) for ev in ch.events] + sig = synthesize(ch.events, ch.is_wave, ch.is_noise, total, + waves=waves, pans=pans) + mix[:len(sig)] += sig + mix *= 0.5 + peak = float(np.max(np.abs(mix))) if mix.size else 0.0 + if peak > 1.0: + mix /= peak # normalize before splitting so intro/loop match + + base = song.removeprefix("Music_").lower() + music_dir = os.path.join(assets_dir, "audio/music") + if split: + intro_n = snap(intro_ticks) + loop_buf = mix[intro_n:] + if intro_n < 32: # no real intro: single seamlessly-looping file + write_wav(os.path.join(music_dir, base + ".wav"), loop_buf) + return {"file": f"assets/generated/audio/music/{base}.wav", + "seconds": round(loop_ticks / TICKS_PER_SECOND, 2), + "intro": False} + write_wav(os.path.join(music_dir, base + ".wav"), mix[:intro_n]) + write_wav(os.path.join(music_dir, base + "_loop.wav"), loop_buf) + return {"file": f"assets/generated/audio/music/{base}.wav", + "seconds": round(intro_ticks / TICKS_PER_SECOND, 2), + "loopFile": f"assets/generated/audio/music/{base}_loop.wav", + "loopSeconds": round(loop_ticks / TICKS_PER_SECOND, 2), + "intro": True} + write_wav(os.path.join(music_dir, base + ".wav"), mix) + return {"file": f"assets/generated/audio/music/{base}.wav", + "seconds": round(total / TICKS_PER_SECOND, 2), "intro": False} + + +def parse_species_order(pokered): + """constants/pokemon_constants.asm: internal id -> species const name.""" + order = {} + idx = 0 + path = os.path.join(pokered, "constants/pokemon_constants.asm") + for lineno, line in read_asm(path): + s = line.strip() + if re.match(r"const_skip\b", s): + idx += 1 + continue + m = re.match(r"const\s+(\w+)", s) + if m: + if m.group(1) != "NO_MON": + order[idx] = m.group(1) + idx += 1 + if "NUM_POKEMON_INDEXES" in s: + break + return order + + +def parse_cries(pokered): + """data/pokemon/cries.asm: internal-order (base cry, pitch, length).""" + cries = [] + path = os.path.join(pokered, "data/pokemon/cries.asm") + for lineno, line in read_asm(path): + m = re.match(r"mon_cry\s+SFX_CRY_([0-9A-F]+),\s*(\$?\w+),\s*(\$?\w+)", + line.strip()) + if m: + cries.append({"base": int(m.group(1), 16), + "pitch": parse_number(m.group(2)), + "length": parse_number(m.group(3))}) + return cries + + +def render_cries(pokered, assets_dir, sfx_streams, sfx_headers): + """Render per-species cry WAVs. + + Each species plays one of 38 base cries (SFX_CryXX_1, channels 5/6/8) + with a per-species frequency modifier (pitch) and tempo modifier + (length) -- data/pokemon/cries.asm + audio/engine_1.asm. + """ + species_by_id = parse_species_order(pokered) + cries = parse_cries(pokered) + out = {} + for internal_id, cry in enumerate(cries, start=1): + species = species_by_id.get(internal_id) + if species is None: + continue # MissingNo. slots + header = f"SFX_Cry{cry['base']:02X}_1" + channels = sfx_headers.get(header) + if not channels: + warn(f"audio: cry {species}: missing header {header}") + continue + chans = [] + total = 0 + for num, label in channels: + if label not in sfx_streams: + continue + # the noise channel (CHAN8) skips Audio1_SetSfxTempo, so the + # cry's tempo modifier does not stretch it + ticks = FRAME_TICKS if num in (4, 8) else 0x80 + cry["length"] + ch = Channel(sfx_streams, label, is_wave=(num in (3, 7)), + is_noise=(num in (4, 8)), + freq_offset=cry["pitch"], frame_ticks=ticks, + is_sfx=True) + ch.run() + if ch.events: + chans.append(ch) + total = max(total, min(ch.time, 5 * TICKS_PER_SECOND)) + if not chans or total <= TICKS_PER_SECOND // 100: + warn(f"audio: cry {species}: nothing rendered") + continue + mix = np.zeros(snap(total) + 1, dtype=np.float32) + for ch in chans: + sig = synthesize(ch.events, ch.is_wave, ch.is_noise, total) + mix[:len(sig)] += sig + mix *= 0.5 + fname = species.lower() + write_wav(os.path.join(assets_dir, "audio/cries", fname + ".wav"), mix) + out[species] = f"assets/generated/audio/cries/{fname}.wav" + if len(out) < 150: + warn(f"audio: only {len(out)} cries rendered") + return out + + +def parse_map_songs(pokered): + out = [] + for lineno, line in read_asm(os.path.join(pokered, "data/maps/songs.asm")): + m = re.match(r"db\s+(MUSIC_\w+),", line.strip()) + if m: + out.append(m.group(1)) + return out + + +def music_const_to_label(const, rendered=None): + # MUSIC_PALLET_TOWN -> Music_PalletTown + parts = const.removeprefix("MUSIC_").split("_") + label = "Music_" + "".join(p.capitalize() for p in parts) + if rendered is not None and label not in rendered: + # labels keep initialisms the naive capitalize() breaks + # (MUSIC_SS_ANNE -> Music_SSAnne): fall back to a + # case-insensitive match against the rendered song names + folded = label.lower() + for k in rendered: + if k.lower() == folded: + return k + return label + + +def sfx_key(name, bank): + """SFX header label -> stable key. Only the bank suffix (the final + _1/_2/_3 matching the header file the label came from, used for + sounds duplicated across banks like SFX_Pound_1/SFX_Pound_3) is + stripped; hex ids like SFX_Battle_09 stay distinct.""" + base = name.removeprefix("SFX_") + suffix = f"_{bank}" + if base.endswith(suffix): + base = base[:-len(suffix)] + return base + + +def extract(pokered, out_dir, assets_dir, map_order): + headers, music_banks = parse_headers(pokered) + streams = parse_music_files(pokered) + wave_banks = parse_wave_instruments(pokered) + + rendered = {} + for song, channels in sorted(headers.items()): + waves = wave_banks.get(music_banks.get(song, 1), wave_banks[1]) + entry = render_song(song, channels, streams, waves, assets_dir) + if entry: + rendered[song] = entry + + # ------------------------------------------------------------- SFX + sfx_headers, sfx_banks = parse_headers(pokered, "sfxheaders", "SFX_") + sfx_dir = os.path.join(pokered, "audio/sfx") + sfx_streams = {} + for fname in sorted(os.listdir(sfx_dir)): + if fname.endswith(".asm"): + sfx_streams.update( + parse_music_files_dir(os.path.join(sfx_dir, fname))) + sfx_out = {} + for name, channels in sorted(sfx_headers.items()): + base = sfx_key(name, sfx_banks.get(name, 1)) + if base.startswith(("Cry", "Noise_Instrument", "Unused")) \ + or base in sfx_out: + continue + waves = wave_banks.get(sfx_banks.get(name, 1), wave_banks[1]) + chans = [] + total = 0 + for num, label in channels: + if label not in sfx_streams: + continue + ch = Channel(sfx_streams, label, is_wave=(num in (3, 7)), + is_noise=(num in (4, 8)), is_sfx=True) + ch.run() + if ch.events: + chans.append(ch) + total = max(total, min(ch.time, 5 * TICKS_PER_SECOND)) + if not chans or total <= TICKS_PER_SECOND // 100: + continue + mix = np.zeros(snap(total) + 1, dtype=np.float32) + for ch in chans: + sig = synthesize(ch.events, ch.is_wave, ch.is_noise, total, + waves=waves) + mix[:len(sig)] += sig + mix *= 0.5 + fname = base.lower() + write_wav(os.path.join(assets_dir, "audio/sfx", fname + ".wav"), mix) + sfx_out[base] = f"assets/generated/audio/sfx/{fname}.wav" + + # ------------------------------------------------- move SFX variants + # data/moves/sfx.asm MoveSoundTable: each move's sound carries a + # pitch modifier (added to every frequency register write) and a + # tempo modifier (wSfxTempo = tempo + $80, scaling note lengths). + # The battle sound engine applies both to battle SFX + # (audio/engine_2.asm Audio2_ApplyFrequencyModifier / + # Audio2_SetSfxTempo; the noise channel CHAN8 skips SetSfxTempo, so + # tempo never stretches noise) -- the same mechanism as cries. + # Pre-synthesize one WAV per distinct non-identity (sfx, pitch, + # tempo) triple; Sound.playMove looks them up via the + # "@" sfx-table keys and falls back to the plain + # sound. GROWL/ROAR rows are excluded: GetMoveSound (IsCryMove) + # plays the attacker's cry for those instead of the table sound. + const_to_label = {} + path = os.path.join(pokered, "constants/music_constants.asm") + for lineno, line in read_asm(path): + m = re.match(r"music_const\s+(\w+)\s*,\s*(\w+)", line.strip()) + if m: + const_to_label[m.group(1)] = m.group(2) + move_rows = [] + path = os.path.join(pokered, "data/moves/sfx.asm") + for lineno, line in read_asm(path): + m = re.match(r"db\s+(SFX_\w+)\s*,\s*(\$?\w+)\s*,\s*(\$?\w+)", + line.strip()) + if m: + move_rows.append((m.group(1), parse_number(m.group(2)), + parse_number(m.group(3)), lineno)) + cry_moves = {45, 46} # GROWL, ROAR (1-based MoveSoundTable rows) + variants = {} + for i, (const, pitch, tempo, lineno) in enumerate(move_rows, start=1): + if (pitch == 0 and tempo == 0x80) or i in cry_moves: + continue # identity modifiers: the base WAV already matches + label = const_to_label.get(const) + if label is None: + warn(f"audio: sfx.asm:{lineno}: unknown sfx constant {const}") + continue + if label not in sfx_headers: + for suffix in ("_1", "_2", "_3"): + if label + suffix in sfx_headers: + label += suffix + break + if label not in sfx_headers: + warn(f"audio: sfx.asm:{lineno}: no header for {label}") + continue + base = sfx_key(label, sfx_banks.get(label, 1)) + variants[(base, pitch, tempo)] = label + for (base, pitch, tempo), label in sorted(variants.items()): + waves = wave_banks.get(sfx_banks.get(label, 1), wave_banks[1]) + chans = [] + total = 0 + for num, lbl in sfx_headers[label]: + if lbl not in sfx_streams: + continue + ticks = FRAME_TICKS if num in (4, 8) else 0x80 + tempo + ch = Channel(sfx_streams, lbl, is_wave=(num in (3, 7)), + is_noise=(num in (4, 8)), + freq_offset=pitch, frame_ticks=ticks, is_sfx=True) + ch.run() + if ch.events: + chans.append(ch) + total = max(total, min(ch.time, 5 * TICKS_PER_SECOND)) + if not chans or total <= TICKS_PER_SECOND // 100: + continue + mix = np.zeros(snap(total) + 1, dtype=np.float32) + for ch in chans: + sig = synthesize(ch.events, ch.is_wave, ch.is_noise, total, + waves=waves) + mix[:len(sig)] += sig + mix *= 0.5 + fname = f"{base.lower()}_p{pitch:02x}t{tempo:02x}.wav" + write_wav(os.path.join(assets_dir, "audio/sfx", fname), mix) + sfx_out[f"{base}@{pitch:02x}{tempo:02x}"] = \ + f"assets/generated/audio/sfx/{fname}" + + # ---------------------------------------------- low health alarm + # audio/low_health_alarm.asm (Music_DoLowHealthAlarm, ticked every + # vblank while the battle sound engine is loaded): raw pulse-1 + # register writes, so it has no sfx header to parse. The timer + # plays the high tone (NR11 $A0 = 50% duty, NR12 $E2 = vol 14 fade + # 2, freq reg $750) at timer reset and the low tone ($B0/$E2, reg + # $6EE) 11 frames later; the dec is skipped on reset frames, so the + # true cycle is 31 frames: an 11-frame high blip then a 20-frame + # low tone. Each write retriggers the envelope, which synthesize() + # models per event. One cycle is 11392.5 samples, so two cycles + # are rendered: 62 frames = exactly 22785 samples at 22050 Hz, a + # seamless loop BattleState plays while the player's HP bar is red. + def alarm_tone(frame, dur_frames, reg): + return {"t": frame * FRAME_TICKS, "dur": dur_frames * FRAME_TICKS, + "reg": reg, "vol": 14, "fade": 2, "duty": 0.5, + "vib": None, "slide": None, + "wave_inst": 0, "wave_level": 0.0} + alarm_events = [alarm_tone(0, 11, 0x750), alarm_tone(11, 20, 0x6EE), + alarm_tone(31, 11, 0x750), alarm_tone(42, 20, 0x6EE)] + alarm = synthesize(alarm_events, False, False, 62 * FRAME_TICKS) * 0.5 + write_wav(os.path.join(assets_dir, "audio/sfx", "low_health_alarm.wav"), + alarm) + sfx_out["Low_Health_Alarm"] = \ + "assets/generated/audio/sfx/low_health_alarm.wav" + + cries = render_cries(pokered, assets_dir, sfx_streams, sfx_headers) + + map_song_consts = parse_map_songs(pokered) + map_songs = {} + for i, const in enumerate(map_song_consts): + if i >= len(map_order): + break + label = music_const_to_label(const, rendered) + if label in rendered: + map_songs[map_order[i]] = label + else: + print(f"WARNING: map song {const} -> {label} has no rendered song; " + f"{map_order[i]} keeps no music assignment") + + data = { + "source": "audio/music/*.asm, audio/sfx/*.asm, audio/headers/*.asm, data/maps/songs.asm", + "songs": rendered, + "sfx": sfx_out, + "cries": cries, + "mapSongs": map_songs, + "battle": { + "wild": "Music_WildBattle", + "trainer": "Music_TrainerBattle", + "gym": "Music_GymLeaderBattle", + "final": "Music_FinalBattle", + "wildWin": "Music_DefeatedWildMon", + "trainerWin": "Music_DefeatedTrainer", + "gymWin": "Music_DefeatedGymLeader", + }, + } + util.write_lua(os.path.join(out_dir, "audio.lua"), data, + header="Synthesized from the real note data; see extraction-notes.md.") + return rendered diff --git a/tools/extract/battle_anims.py b/tools/extract/battle_anims.py new file mode 100644 index 00000000..41975376 --- /dev/null +++ b/tools/extract/battle_anims.py @@ -0,0 +1,472 @@ +"""Extract composed battle move animations (beams, blobs, projectiles...). + +Sources: + data/moves/animations.asm + AttackAnimationPointers: one label per move (id order, NUM_ATTACKS=165). + Each block is battle_anim rows terminated by `db -1`. The battle_anim + macro (defined in the same file) has two forms: + 4 args: battle_anim sound_move, subanim_id, tileset_id, frame_delay + -> db (tileset << 6) | delay, sound - 1, subanim + 2 args: battle_anim sound_move, special_effect_id (SE_*, >= $C0) + -> db effect, sound - 1 + (PlayAnimation in engine/battle/animations.asm:164 dispatches on the + first byte: >= FIRST_SE_ID is a special effect.) + data/battle_anims/subanimations.asm + SubanimationPointers + per subanimation: + db (SUBANIMTYPE_* << 5) | frame_block_count (`subanim` macro) + then count * `db frame_block_id, base_coord_id, frame_block_mode` + (decoded by LoadSubanimation, engine/battle/animations.asm:270.) + data/battle_anims/frame_blocks.asm + FrameBlockPointers + per frame block: db tile_count, then tile_count * + dbsprite x_tile, y_tile, x_px, y_px, tile, attrs -> OAM entry + (y offset, x offset, tile, attrs); macros/gfx.asm:19. Offsets are + relative to the base coordinate; attrs use OAM_XFLIP/OAM_YFLIP/OAM_PRIO. + (drawn by DrawFrameBlock, engine/battle/animations.asm:3.) + data/battle_anims/base_coords.asm + FrameBlockBaseCoords: db y, x pairs in OAM space (screen y+16, x+8). + constants/move_animation_constants.asm + SE_* / SUBANIM_* / FRAMEBLOCK_* / BASECOORD_* / FRAMEBLOCKMODE_* / + SUBANIMTYPE_* values. + engine/battle/animations.asm + MoveAnimationTilesPointers (anim_tileset count, gfx label) + INCBINs + -> which PNG each tileset id (upper 2 bits of the battle_anim first + byte) uses and how many tiles are loaded. + gfx/battle/move_anim_0.png, move_anim_1.png -> tilesheets (16 tiles/row). + +Output: + data/generated/battle_anims.lua + assets/generated/battle/anims/move_anim_*.png (color 0 transparent; these + are OAM sprites) + +Playback semantics (subanimation types, frame block modes, enemy-turn +mirroring) are implemented in src/battle/AnimPlayer.lua. +""" + +import os +import re + +from . import gfx, util +from .util import parse_number, read_asm, split_args + +NUM_ATTACKS = 165 + +SUBANIMTYPE_NAMES = [ + "NORMAL", "HVFLIP", "HFLIP", "COORDFLIP", "REVERSE", "ENEMY", +] + +OAM_FLAGS = {"OAM_XFLIP": 0x20, "OAM_YFLIP": 0x40, "OAM_PRIO": 0x80, + "OAM_PAL0": 0x00, "OAM_PAL1": 0x10} + + +def parse_anim_constants(pokered): + """name -> value for every const in constants/move_animation_constants.asm + (multiple const_def blocks; handles const_def N and const_skip N).""" + path = os.path.join(pokered, "constants/move_animation_constants.asm") + values = {} + value = None + for lineno, line in read_asm(path): + s = line.strip() + if not s: + continue + m = re.match(r"const_def(?:\s+(\S+))?$", s) + if m: + value = parse_number(m.group(1)) if m.group(1) else 0 + continue + m = re.match(r"const_skip(?:\s+(\S+))?$", s) + if m and value is not None: + value += parse_number(m.group(1)) if m.group(1) else 1 + continue + m = re.match(r"const\s+(\w+)$", s) + if m and value is not None: + values[m.group(1)] = value + value += 1 + if "SE_SHAKE_SCREEN" not in values or "FRAMEBLOCKMODE_04" not in values: + util.die("move_animation_constants.asm: expected constants not found") + return values + + +def parse_pointer_table(lines, table_label, path, whole_table=False): + """Labels of a `dw` pointer table, up to the first assert_table_length + (or, with whole_table, through interior asserts to the table's end -- + AttackAnimationPointers continues past NUM_ATTACKS with the ball + toss/poof and status animation entries).""" + labels = [] + in_table = False + for lineno, line in lines: + s = line.strip() + if s == table_label + ":": + in_table = True + continue + if in_table: + if s.startswith("assert_table_length"): + if not whole_table: + return labels + continue + m = re.match(r"dw\s+(\w+)$", s) + if m: + labels.append(m.group(1)) + continue + if s and not s.startswith("table_width"): + return labels # end of table (whole_table) + util.die(f"{path}: pointer table {table_label} not found/unterminated") + + +def parse_base_coords(pokered): + path = os.path.join(pokered, "data/battle_anims/base_coords.asm") + coords = [] + for lineno, line in read_asm(path): + s = line.strip() + if s.startswith("assert_table_length"): + break + m = re.match(r"db\s+(\S+)\s*,\s*(\S+)$", s) + if m: + coords.append({"y": parse_number(m.group(1)), + "x": parse_number(m.group(2))}) + if len(coords) != 0xB1: # BASECOORD_00..BASECOORD_B0 + util.die(f"base_coords.asm: expected 177 coords, got {len(coords)}") + return coords + + +def _parse_attrs(argstr): + flags = 0 + for tok in argstr.split("|"): + tok = tok.strip() + if tok in OAM_FLAGS: + flags |= OAM_FLAGS[tok] + else: + flags |= parse_number(tok) + return flags + + +def parse_frame_blocks(pokered): + """FrameBlockPointers order -> list of frame blocks; each is a list of + { y, x, tile, xflip, yflip [, prio] } OAM entries (offsets mod 256).""" + path = os.path.join(pokered, "data/battle_anims/frame_blocks.asm") + lines = read_asm(path) + order = parse_pointer_table(lines, "FrameBlockPointers", path) + + bodies = {} # label -> list of entries + counts = {} # label -> declared tile count + cur = None # list currently being filled + cur_labels = [] # labels awaiting their `db count` line + for lineno, line in lines: + s = line.strip() + if not s or s.startswith(("dw ", "table_width", "assert_table_length", + "INCLUDE")): + continue + m = re.match(r"(\w+)::?$", s) + if m: + if m.group(1) in order: + if m.group(1) in bodies: + util.die(f"{path}:{lineno}: duplicate body {m.group(1)}") + cur_labels.append(m.group(1)) + else: + cur_labels = [] # FrameBlockBaseCoords etc. + cur = None + continue + m = re.match(r"dbsprite\s+(.*)$", s) + if m: + if cur is None: + continue + a = split_args(m.group(1)) + if len(a) != 6: + util.die(f"{path}:{lineno}: dbsprite wants 6 args, got {a}") + attrs = _parse_attrs(a[5]) + entry = { + # macros/gfx.asm dbsprite: db (ytile*8)+ypx, (xtile*8)+xpx, + # tile, attrs -- i.e. (y offset, x offset, tile, attrs) + "y": (parse_number(a[1]) * 8 + parse_number(a[3])) & 0xFF, + "x": (parse_number(a[0]) * 8 + parse_number(a[2])) & 0xFF, + "tile": parse_number(a[4]), + "xflip": bool(attrs & OAM_FLAGS["OAM_XFLIP"]), + "yflip": bool(attrs & OAM_FLAGS["OAM_YFLIP"]), + } + if attrs & OAM_FLAGS["OAM_PRIO"]: + entry["prio"] = True + if attrs & OAM_FLAGS["OAM_PAL1"]: + entry["pal1"] = True # drawn with OBP1 ($6c) on the GB + cur.append(entry) + continue + m = re.match(r"db\s+(\S+)$", s) + if m: + if cur_labels: + cur = [] + n = parse_number(m.group(1)) + for label in cur_labels: + bodies[label] = cur + counts[label] = n + cur_labels = [] + # else: trailing `db $00 ; unused` filler -- ignore + continue + + blocks = [] + for label in order: + if label not in bodies: + util.die(f"{path}: missing body for {label}") + if len(bodies[label]) < counts[label]: + util.die(f"{path}: {label} declares {counts[label]} tiles " + f"but has {len(bodies[label])}") + if len(bodies[label]) > counts[label]: + # FrameBlock62 has 16 dbsprite rows but a count byte of 15; the + # engine only ever draws the declared count. + util.warn(f"frame_blocks.asm: {label} declares {counts[label]} " + f"tiles but has {len(bodies[label])}; truncating") + blocks.append(bodies[label][:counts[label]]) + return blocks + + +def parse_subanimations(pokered, n_frame_blocks, n_base_coords, consts): + """SubanimationPointers order -> + { type = SUBANIMTYPE name, blocks = [{ block, coord, mode }, ...] }. + First byte is (SUBANIMTYPE << 5) | count (`subanim` macro, + data/battle_anims/subanimations.asm:97).""" + path = os.path.join(pokered, "data/battle_anims/subanimations.asm") + lines = read_asm(path) + order = parse_pointer_table(lines, "SubanimationPointers", path) + + bodies = {} + cur = None + cur_labels = [] + for lineno, line in lines: + s = line.strip() + if not s: + continue + m = re.match(r"(\w+)::?$", s) + if m: + if m.group(1) in order: + if m.group(1) in bodies: + util.die(f"{path}:{lineno}: duplicate body {m.group(1)}") + cur_labels.append(m.group(1)) + else: + cur_labels = [] + cur = None + continue + m = re.match(r"subanim\s+(\w+)\s*,\s*(\S+)$", s) + if m: + if not cur_labels: + continue # the macro definition body itself + if m.group(1) not in consts: + util.die(f"{path}:{lineno}: unknown type {m.group(1)}") + cur = { + "type": SUBANIMTYPE_NAMES[consts[m.group(1)]], + "count": parse_number(m.group(2)), + "blocks": [], + } + for label in cur_labels: + bodies[label] = cur + cur_labels = [] + continue + m = re.match(r"db\s+(\w+)\s*,\s*(\w+)\s*,\s*(\w+)$", s) + if m: + if cur is None: + continue + for name in m.groups(): + if name not in consts: + util.die(f"{path}:{lineno}: unknown constant {name}") + block, coord, mode = (consts[n] for n in m.groups()) + if block >= n_frame_blocks: + util.die(f"{path}:{lineno}: frame block {block} out of range") + if coord >= n_base_coords: + util.die(f"{path}:{lineno}: base coord {coord} out of range") + cur["blocks"].append({"block": block, "coord": coord, + "mode": mode}) + continue + + subanims = [] + for label in order: + if label not in bodies: + util.die(f"{path}: missing body for {label}") + body = bodies[label] + if len(body["blocks"]) != body["count"]: + util.die(f"{path}: {label} declares {body['count']} frame blocks " + f"but has {len(body['blocks'])}") + subanims.append({"type": body["type"], "blocks": body["blocks"]}) + return subanims + + +def parse_move_anims(pokered, move_order, consts, n_subanims): + """Per move constant: source line + list of rows + { subanim, tileset, delay [, sound] } or { effect = "SE_*" [, sound] }.""" + path = os.path.join(pokered, "data/moves/animations.asm") + lines = read_asm(path) + pointers = parse_pointer_table(lines, "AttackAnimationPointers", path, + whole_table=True) + if len(pointers) < len(move_order): + util.die(f"{path}: {len(pointers)} anim pointers < " + f"{len(move_order)} moves") + + anims = {} # label -> (start lineno, list of rows) + cur = None + prev_was_label = False + for lineno, line in lines: + s = line.strip() + if not s: + continue + m = re.match(r"(\w+)::?$", s) + if m: + if not prev_was_label: + cur = (lineno, []) + anims[m.group(1)] = cur # consecutive labels alias one block + prev_was_label = True + continue + prev_was_label = False + m = re.match(r"battle_anim\s+(.*)$", s) + if m and cur is not None: + a = split_args(m.group(1)) + if len(a) == 2: + if not a[1].startswith("SE_") or a[1] not in consts: + util.die(f"{path}:{lineno}: unknown special effect {a[1]}") + row = {"effect": a[1]} + elif len(a) == 4: + if a[1] not in consts: + util.die(f"{path}:{lineno}: unknown subanimation {a[1]}") + subanim = consts[a[1]] + if subanim >= n_subanims: + util.die(f"{path}:{lineno}: subanim {subanim} " + f"out of range") + delay = parse_number(a[3]) + if not 0 < delay <= 63: + util.die(f"{path}:{lineno}: delay {delay} out of range") + row = { + "subanim": subanim, + "tileset": parse_number(a[2]), + "delay": delay, + } + else: + util.die(f"{path}:{lineno}: battle_anim wants 2 or 4 args") + if a[0] != "NO_MOVE": + row["sound"] = a[0] + cur[1].append(row) + + out = {} + for i, move in enumerate(move_order): + label = pointers[i] + if label not in anims: + util.die(f"{path}: missing animation block {label}") + start, rows = anims[label] + out[move] = { + "source": f"data/moves/animations.asm:{start}", + "seq": rows, + } + if len(out) != len(move_order): + util.die(f"{path}: extracted {len(out)} move anims, " + f"expected {len(move_order)}") + return out + + +def parse_tilesheets(pokered, assets_dir): + """MoveAnimationTilesPointers (engine/battle/animations.asm) -> per + battle-anim tileset id: converted PNG path + tile count. Tileset ids 0 + and 2 share gfx/battle/move_anim_0.png (2 loads only 64 tiles).""" + path = os.path.join(pokered, "engine/battle/animations.asm") + lines = read_asm(path) + rows = [] # (tile count, gfx label) in tileset id order + incbins = {} # gfx label -> source png (relative to pokered) + pending = [] + for lineno, line in lines: + s = line.strip() + m = re.match(r"anim_tileset\s+(\S+)\s*,\s*(\w+)$", s) + if m: + rows.append((parse_number(m.group(1)), m.group(2))) + continue + m = re.match(r"(\w+)::?$", s) + if m: + pending.append(m.group(1)) + continue + m = re.match(r'INCBIN\s+"([^"]+)"$', s) + if m: + for label in pending: + incbins[label] = re.sub(r"\.2bpp$", ".png", m.group(1)) + pending = [] + continue + if s: + pending = [] + if len(rows) != 3: + util.die(f"{path}: expected 3 anim_tileset rows, got {len(rows)}") + + sheets = {} + converted = {} + for tileset_id, (n_tiles, label) in enumerate(rows): + if label not in incbins: + util.die(f"{path}: no INCBIN found for {label}") + src_rel = incbins[label] + base = os.path.basename(src_rel) + if src_rel not in converted: + size = gfx.convert_png( + os.path.join(pokered, src_rel), + os.path.join(assets_dir, "battle", "anims", base), + transparent_color0=True) + converted[src_rel] = size + w, h = converted[src_rel] + sheets[tileset_id] = { + "path": f"assets/generated/battle/anims/{base}", + "width": w, + "height": h, + "tiles": n_tiles, + "source": src_rel, + } + return sheets + + +# animation ids past the moves (constants/move_constants.asm after +# STRUGGLE): ball tosses, the send-out POOF, status/trade animations +MISC_ANIMS = [ + "SHOWPIC_ANIM", "STATUS_AFFECTED_ANIM", "ANIM_A8", + "ENEMY_HUD_SHAKE_ANIM", "TRADE_BALL_DROP_ANIM", + "TRADE_BALL_SHAKE_ANIM", "TRADE_BALL_TILT_ANIM", + "TRADE_BALL_POOF_ANIM", "XSTATITEM_ANIM", "XSTATITEM_DUPLICATE_ANIM", + "SHRINKING_SQUARE_ANIM", "ANIM_B1", "ANIM_B2", "ANIM_B3", "ANIM_B4", + "ANIM_B5", "ANIM_B6", "ANIM_B7", "ANIM_B8", "ANIM_B9", + "BURN_PSN_ANIM", "ANIM_BB", "SLP_PLAYER_ANIM", "SLP_ANIM", + "CONF_PLAYER_ANIM", "CONF_ANIM", "SLIDE_DOWN_ANIM", "TOSS_ANIM", + "SHAKE_ANIM", "POOF_ANIM", "BLOCKBALL_ANIM", "GREATTOSS_ANIM", + "ULTRATOSS_ANIM", "SHAKE_SCREEN_ANIM", "HIDEPIC_ANIM", "ROCK_ANIM", + "BAIT_ANIM", +] + + +def extract(pokered, out_dir, assets_dir, move_order): + if len(move_order) != NUM_ATTACKS: + util.die(f"battle_anims: expected {NUM_ATTACKS} moves, " + f"got {len(move_order)}") + move_order = list(move_order) + MISC_ANIMS + consts = parse_anim_constants(pokered) + base_coords = parse_base_coords(pokered) + frame_blocks = parse_frame_blocks(pokered) + subanims = parse_subanimations(pokered, len(frame_blocks), + len(base_coords), consts) + move_anims = parse_move_anims(pokered, move_order, consts, len(subanims)) + tilesheets = parse_tilesheets(pokered, assets_dir) + + # sanity: every referenced tile must fit its sheet + for move, anim in move_anims.items(): + for row in anim["seq"]: + if "subanim" not in row: + continue + sheet = tilesheets[row["tileset"]] + for entry in subanims[row["subanim"]]["blocks"]: + for t in frame_blocks[entry["block"]]: + if t["tile"] >= sheet["tiles"]: + util.die(f"{move}: tile {t['tile']} out of range for " + f"tileset {row['tileset']}") + + out = { + # indexes are the ROM's 0-based ids throughout + "tilesheets": tilesheets, + "baseCoords": {i: c for i, c in enumerate(base_coords)}, + "frameBlocks": {i: b for i, b in enumerate(frame_blocks)}, + "subanims": {i: s for i, s in enumerate(subanims)}, + "moveAnims": move_anims, + } + util.write_lua( + os.path.join(out_dir, "battle_anims.lua"), out, + header="Sources: data/moves/animations.asm (battle_anim rows),\n" + "data/battle_anims/{subanimations,frame_blocks,base_coords}" + ".asm,\n" + "constants/move_animation_constants.asm, " + "engine/battle/animations.asm,\n" + "gfx/battle/move_anim_*.png.\n" + "Coordinates are OAM-space (screen x+8, y+16); offsets and\n" + "flip math are 8-bit like the GB. Playback: " + "src/battle/AnimPlayer.lua.") + return out diff --git a/tools/extract/constants.py b/tools/extract/constants.py new file mode 100644 index 00000000..5e3003aa --- /dev/null +++ b/tools/extract/constants.py @@ -0,0 +1,97 @@ +"""Extract constants from pret/pokered. + +Sources: + constants/map_constants.asm -> map ids + block dimensions + constants/tileset_constants.asm -> tileset ids + constants/sprite_constants.asm -> overworld sprite ids + constants/pokemon_constants.asm -> internal species order + constants/pokedex_constants.asm -> dex order + constants/move_constants.asm -> move ids + constants/item_constants.asm -> item ids + constants/type_constants.asm -> type ids + constants/hide_show_constants.asm-> toggleable object ids (unused for now) +""" + +import os +import re + +from . import util +from .util import parse_number, read_asm + + +def extract_map_constants(pokered): + """Parse map_const NAME, width, height entries in id order.""" + path = os.path.join(pokered, "constants/map_constants.asm") + order, dims = [], {} + value = None + for lineno, line in read_asm(path): + s = line.strip() + if re.match(r"const_def", s): + value = 0 + continue + m = re.match(r"map_const\s+(\w+),\s*([\d$%-]+),\s*([\d$%-]+)", s) + if m and value is not None: + name = m.group(1) + order.append(name) + dims[name] = { + "index": value, + "width": parse_number(m.group(2)), + "height": parse_number(m.group(3)), + } + value += 1 + if not order or order[0] != "PALLET_TOWN": + util.die("map_constants.asm did not parse as expected") + return order, dims + + +def extract_simple(pokered, relpath, stop_at=None): + return util.parse_const_block(os.path.join(pokered, relpath), stop_at=stop_at) + + +def extract_types(pokered): + """Type constants are physical IDs, a gap, then special IDs at $14.""" + path = os.path.join(pokered, "constants/type_constants.asm") + types = {} + value = None + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) + if m: + value = parse_number(m.group(1)) if m.group(1) else 0 + continue + m = re.match(r"const_next\s+(\$?\w+)$", s) + if m: + value = parse_number(m.group(1)) + continue + m = re.match(r"const\s+(\w+)", s) + if m and value is not None: + types[m.group(1)] = value + value += 1 + if types.get("NORMAL") != 0 or "PSYCHIC_TYPE" not in types: + util.die("type_constants.asm did not parse as expected") + return types + + +def extract(pokered, out_dir): + map_order, map_dims = extract_map_constants(pokered) + tilesets = [n for n in extract_simple(pokered, "constants/tileset_constants.asm") if n] + sprites = extract_simple(pokered, "constants/sprite_constants.asm") + species = extract_simple(pokered, "constants/pokemon_constants.asm") + moves = extract_simple(pokered, "constants/move_constants.asm", stop_at="NUM_ATTACKS") + types = extract_types(pokered) + + # index 0 is the null entry (NO_MON / NO_MOVE / SPRITE_NONE); dropping it + # makes the Lua arrays line up so array index == game id. + data = { + "source": "constants/*.asm", + "mapOrder": map_order, + "maps": map_dims, + "tilesetOrder": tilesets, + "spriteOrder": [n or "UNUSED" for n in sprites[1:]], + "speciesOrder": [n or "UNUSED" for n in species[1:]], + "moveOrder": [n or "UNUSED" for n in moves[1:]], + "types": types, + } + util.write_lua(os.path.join(out_dir, "constants.lua"), data, + header="Source: pret/pokered constants/*.asm") + return data diff --git a/tools/extract/encounters.py b/tools/extract/encounters.py new file mode 100644 index 00000000..5705b79d --- /dev/null +++ b/tools/extract/encounters.py @@ -0,0 +1,88 @@ +"""Extract wild encounter tables. + +Sources: + data/wild/grass_water.asm -> WildDataPointers (one entry per map id) + data/wild/maps/*.asm -> def_grass_wildmons rate / db level, species x10 + +Output: data/generated/encounters.lua (keyed by map constant) +""" + +import os +import re + +from . import util +from .util import parse_number, read_asm, split_args, warn + + +def parse_wild_file(path, rel): + grass = {"rate": 0, "slots": []} + water = {"rate": 0, "slots": []} + current = None + label = None + out = {} + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"(\w+):{1,2}\s*$", s) + if m: + label = m.group(1) + grass, water = {"rate": 0, "slots": []}, {"rate": 0, "slots": []} + out[label] = {"grass": grass, "water": water, "source": rel} + continue + m = re.match(r"def_grass_wildmons\s+(\d+)", s) + if m: + grass["rate"] = int(m.group(1)) + current = grass + continue + m = re.match(r"def_water_wildmons\s+(\d+)", s) + if m: + water["rate"] = int(m.group(1)) + current = water + continue + if s.startswith(("end_grass_wildmons", "end_water_wildmons")): + current = None + continue + m = re.match(r"db\s+(.*)$", s) + if m and current is not None: + a = split_args(m.group(1)) + if len(a) == 2: + current["slots"].append({"level": parse_number(a[0]), "species": a[1]}) + return out + + +def extract(pokered, out_dir, map_order): + tables = {} + wild_dir = os.path.join(pokered, "data/wild/maps") + for fname in sorted(os.listdir(wild_dir)): + if fname.endswith(".asm"): + tables.update(parse_wild_file(os.path.join(wild_dir, fname), + f"data/wild/maps/{fname}")) + + pointers = [] + for lineno, line in read_asm(os.path.join(pokered, "data/wild/grass_water.asm")): + m = re.match(r"dw\s+(\w+)$", line.strip()) + if m: + pointers.append(m.group(1)) + + out = {} + for i, label in enumerate(pointers): + if i >= len(map_order): + break + if label == "NothingWildMons": + continue + t = tables.get(label) + if t is None: + warn(f"grass_water.asm: no wild table {label}") + continue + entry = {"source": t["source"]} + if t["grass"]["rate"] > 0 or t["grass"]["slots"]: + entry["grass"] = t["grass"] + if t["water"]["rate"] > 0 or t["water"]["slots"]: + entry["water"] = t["water"] + out[map_order[i]] = entry + + if "ROUTE_1" not in out or out["ROUTE_1"]["grass"]["rate"] != 25: + util.die("encounter extraction sanity check failed (ROUTE_1)") + util.write_lua(os.path.join(out_dir, "encounters.lua"), out, + header="Sources: data/wild/grass_water.asm, data/wild/maps/*.asm\n" + "10 grass slots; slot probabilities live in the engine (Gen 1 buckets).") + return out diff --git a/tools/extract/field.py b/tools/extract/field.py new file mode 100644 index 00000000..c3223695 --- /dev/null +++ b/tools/extract/field.py @@ -0,0 +1,1493 @@ +"""Extract field-interaction data: ledges, cut trees, water tilesets. + +Sources: + data/tilesets/ledge_tiles.asm -> hop rules (facing, stand, ledge, pad) + data/tilesets/cut_tree_blocks.asm -> block swap after Cut + data/tilesets/water_tilesets.asm -> tilesets where Surf works + data/events/hidden_events.asm -> hidden items / coins / slot machines, + PC tiles, bench guys, gym statues, + Vermilion Gym trash cans + data/events/bench_guys.asm -> bench guy text per map + data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists + data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm + -> Silph Co card key doors + data/maps/force_bike_surf.asm + home/overworld.asm + -> forced bike/surf tiles, Cycling Road + scripts/SeafoamIslandsB{2,3,4}F.asm -> surf currents and boulder holes + scripts/GameCorner.asm -> Rocket Hideout poster block swap + scripts/Route22Gate.asm, scripts/Route23.asm -> badge gates + constants/player_constants.asm -> preset player/rival names + engine/events/hidden_events/vermilion_gym_trash.asm -> trash can puzzle + scripts/{ViridianGym,RocketHideoutB2F,RocketHideoutB3F}.asm + -> spinner arrow tile movement tables + gfx/title/*.png, gfx/splash/copyright.png -> title screen assets (via gfx.py) + gfx/splash/*, gfx/intro/*, engine/movie/{splash,intro}.asm + -> intro movie assets (via gfx.py) + data/maps/{town_map_entries,town_map_order,names}.asm + -> town map locations + cursor order + data/credits/*.asm + engine/movie/credits.asm -> end credits + THE END + constants/script_constants.asm + gfx/slots/*, gfx/emotes/* + -> slot wheel symbols, emotion bubbles + scripts/ViridianCity.asm -> old man catch-demo battle + scripts/GameCorner.asm + text/GameCorner.asm -> coin purchases + constants/menu_constants.asm -> PC item capacity + +Output: data/generated/field.lua + (+ assets/generated/{title,slots,credits}/*.png, assets/generated/emotes.png) +""" + +import os +import re + +from . import gfx, text, util +from .util import parse_number, read_asm, split_args + +DIRS = { + "SPRITE_FACING_DOWN": "down", "SPRITE_FACING_UP": "up", + "SPRITE_FACING_LEFT": "left", "SPRITE_FACING_RIGHT": "right", +} + + +def parse_fly_warps(pokered): + """data/maps/special_warps.asm: fly_warp MAP, x, y landing spots.""" + warps = {} + order = [] + for lineno, line in read_asm(os.path.join(pokered, "data/maps/special_warps.asm")): + m = re.match(r"\.?\w*:?\s*fly_warp\s+(\w+),\s*(\d+),\s*(\d+)", line.strip()) + if m: + warps[m.group(1)] = {"x": int(m.group(2)), "y": int(m.group(3))} + order.append(m.group(1)) + return warps, order + + +def parse_super_rod(pokered): + """data/wild/super_rod.asm: map -> fishing group of (level, species).""" + groups = {} + per_map = [] + current = None + for lineno, line in read_asm(os.path.join(pokered, "data/wild/super_rod.asm")): + s = line.strip() + m = re.match(r"dbw\s+(\w+),\s*\.(\w+)", s) + if m: + per_map.append((m.group(1), m.group(2))) + continue + m = re.match(r"\.(\w+):?\s*$", s) + if m: + current = m.group(1) + groups[current] = [] + continue + m = re.match(r"db\s+(\d+),\s*(\w+)$", s) + if m and current: + groups[current].append({"level": int(m.group(1)), "species": m.group(2)}) + out = {} + for map_id, group in per_map: + out[map_id] = groups.get(group, []) + return out + + +def parse_trades(pokered): + """data/events/trades.asm: npctrade give, get, dialogset, nickname.""" + # TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes + # InGameTradeTextPointers -> TradeTextPointers1/2/3 + # (engine/events/in_game_trades.asm); stored 1-based to match the + # _WannaTradeText/_AfterTradeText/... label numbering. + dialogsets = { + "TRADE_DIALOGSET_CASUAL": 1, + "TRADE_DIALOGSET_EVOLUTION": 2, + "TRADE_DIALOGSET_HAPPY": 3, + } + trades = [] + for lineno, line in read_asm(os.path.join(pokered, "data/events/trades.asm")): + m = re.match(r'npctrade\s+(\w+),\s*(\w+),\s*(\w+),\s*"([^"]*)"', line.strip()) + if m: + trades.append({ + "give": m.group(1), # what the NPC wants from the player + "get": m.group(2), # what the NPC hands over + "dialogset": dialogsets.get(m.group(3), 1), + "nickname": m.group(4), + }) + return trades + + +def parse_hidden_events(pokered): + """data/events/hidden_events.asm: per-map hidden_event x, y, Func, arg. + + Keeps the three data-driven kinds: HiddenItems (item pickups the + Itemfinder detects), HiddenCoins (Game Corner floor coins) and + StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). + Also collects the engine text hooks that the port implements natively: + OpenPokemonCenterPC, PrintBenchGuyText, GymStatues and the Vermilion + Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the + fourth macro argument is the facing direction required to trigger the + event, except GymTrashScript where it is the can index. + """ + items = {} + coins = {} + slots = {} + extras = {"pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "trashCans": []} + current = None + path = os.path.join(pokered, "data/events/hidden_events.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"hidden_events_for\s+(\w+)", s) + if m: + current = m.group(1) + continue + m = re.match(r"hidden_event\s+(\d+),\s*(\d+),\s*(\w+),\s*(.+)$", s) + if not m or not current: + continue + x, y, func, arg = int(m.group(1)), int(m.group(2)), m.group(3), m.group(4).strip() + if func == "HiddenItems": + items.setdefault(current, []).append({"x": x, "y": y, "item": arg}) + elif func == "HiddenCoins": + cm = re.match(r"COIN\s*\+\s*(\d+)", arg) + if cm: + coins.setdefault(current, []).append( + {"x": x, "y": y, "coins": int(cm.group(1))}) + elif func == "StartSlotMachine": + state = "ok" + if arg == "SLOTS_OUTOFORDER": + state = "out_of_order" + elif arg == "SLOTS_OUTTOLUNCH": + state = "out_to_lunch" + elif arg == "SLOTS_SOMEONESKEYS": + state = "keys" + slots.setdefault(current, []).append({"x": x, "y": y, "state": state}) + elif func == "OpenPokemonCenterPC": + extras["pcTiles"].setdefault(current, []).append( + {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) + elif func == "PrintBenchGuyText": + extras["benchGuys"].setdefault(current, []).append( + {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) + elif func == "GymStatues": + extras["gymStatues"].setdefault(current, []).append( + {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) + elif func == "GymTrashScript": + if current != "VERMILION_GYM": + util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside VERMILION_GYM") + extras["trashCans"].append({"x": x, "y": y, "can": int(arg)}) + return items, coins, slots, extras + + +def parse_bench_guy_texts(pokered): + """data/events/bench_guys.asm: bench_guy_text map, facing, text. + + PrintBenchGuyText (engine/events/hidden_events/bench_guys.asm) looks up + wCurMap in this table and shows the text if the player's facing matches + the table entry (a table bug misaligns the scan when it does not match, + e.g. VERMILION_POKECENTER triggers facing up but its entry says left). + """ + texts = {} + path = os.path.join(pokered, "data/events/bench_guys.asm") + for lineno, line in read_asm(path): + m = re.match(r"bench_guy_text\s+(\w+),\s*(SPRITE_FACING_\w+),\s*(\w+)", + line.strip()) + if m: + texts[m.group(1)] = {"facing": DIRS[m.group(2)], "text": m.group(3)} + return texts + + +def parse_trash_can_puzzle(pokered, cans): + """engine/events/hidden_events/vermilion_gym_trash.asm GymTrashScript. + + Puzzle rules (see also scripts/VermilionCity.asm .setFirstLockTrashCanIndex): + * The first switch is placed when Vermilion City loads: + wFirstLockTrashCanIndex = Random & $0e, i.e. one of the 8 + even-indexed cans 0,2,..,14. + * Opening it sets EVENT_1ST_LOCK_OPENED; the second switch is then + picked at random from the first can's row in the GymTrashCans + adjacency table (byte 0 = candidate count, bytes 1-4 = candidate can + indices). A signed-offset bug can make the pick fall outside the + row, in which case can 0 gets the second switch. + * Searching any other can resets EVENT_1ST_LOCK_OPENED, prints the + fail text and rerandomizes the first can (Random & $0e again). + * Finding the second switch sets EVENT_2ND_LOCK_OPENED (doors open). + The 15 cans sit at x = 1,3,5,7,9 / y = 7,9,11 (five map columns of + three); can index = 3*(x-1)/2 + (y-7)/2, so adjacency entries differ by + 1 (vertical neighbour, same column) or 3 (horizontal neighbour). + """ + path = os.path.join(pokered, "engine/events/hidden_events/vermilion_gym_trash.asm") + adjacency = {} + in_table = False + text = [] + for lineno, line in read_asm(path): + s = line.strip() + text.append(s) + if s == "GymTrashCans:": + in_table = True + continue + if in_table: + m = re.match(r"db\s+(\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)$", s) + if m: + count = int(m.group(1)) + row = [int(m.group(i)) for i in range(2, 6)][:count] + adjacency[len(adjacency)] = row + elif s: + in_table = False + joined = "\n".join(text) + if "and $e" not in joined or "SetEvent EVENT_2ND_LOCK_OPENED" not in joined: + util.die("vermilion_gym_trash.asm: puzzle randomization code changed") + if len(adjacency) != 15: + util.die(f"vermilion_gym_trash.asm: expected 15 GymTrashCans rows, got {len(adjacency)}") + for can in cans: + idx = 3 * (can["x"] - 1) // 2 + (can["y"] - 7) // 2 + if idx != can["can"]: + util.die(f"trash can index/coord mismatch: {can}") + for adj in adjacency[can["can"]]: + if abs(adj - can["can"]) not in (1, 3): + util.die(f"trash can adjacency not a grid neighbour: {can['can']} -> {adj}") + return { + "map": "VERMILION_GYM", + "cans": cans, + "adjacent": adjacency, # can index -> cans that may hold switch 2 + "firstLockCandidates": list(range(0, 15, 2)), # Random & $0e + "firstLockEvent": "EVENT_1ST_LOCK_OPENED", + "secondLockEvent": "EVENT_2ND_LOCK_OPENED", + "columns": 5, "rows": 3, # physical layout; index = 3*(x-1)/2 + (y-7)/2 + "rules": "first switch: random even can (Random & $0e, rolled on Vermilion City load); " + "second switch: random can adjacent to the first (GymTrashCans table); " + "a wrong second can relocks and rerandomizes the first switch", + } + + +def parse_slot_wheels(pokered): + """data/events/slot_machine_wheels.asm: three symbol sequences.""" + wheels = [] + current = None + path = os.path.join(pokered, "data/events/slot_machine_wheels.asm") + for lineno, line in read_asm(path): + s = line.strip() + if re.match(r"SlotMachineWheel\d:", s): + current = [] + wheels.append(current) + continue + m = re.match(r"dw\s+SLOTS(\w+)$", s) + if m and current is not None: + current.append(m.group(1)) # 7, MOUSE, FISH, BAR, CHERRY, BIRD + return wheels + + +def parse_card_key_doors(pokered): + """Silph Co card key doors. + + data/events/card_key_coords.asm lists the door tile coords as + `db map, Y, X, gate id` (the three tables are unused by the engine but + match the real door positions). The engine (engine/events/card_key.asm + PrintCardKeyText) instead works on any map in SilphCoMapList + (data/events/card_key_maps.asm): if the tile in front of the player is + $18 or $24 (locked door tiles, FACILITY tileset) -- or $5e on + SILPH_CO_11F -- and the player has the CARD_KEY, it halves the tile + coords to block coords and replaces that block with $0e (open door; + $03 on SILPH_CO_11F). + """ + doors = {} + n_doors = 0 + path = os.path.join(pokered, "data/events/card_key_coords.asm") + for lineno, line in read_asm(path): + m = re.match(r"db\s+(SILPH_CO_\w+),\s*(\$\w+),\s*(\$\w+),\s*(\d+)$", + line.strip()) + if m: + doors.setdefault(m.group(1), []).append( + {"x": parse_number(m.group(3)), "y": parse_number(m.group(2)), + "gate": int(m.group(4))}) + n_doors += 1 + maps = [] + for lineno, line in read_asm(os.path.join(pokered, "data/events/card_key_maps.asm")): + m = re.match(r"db\s+(SILPH_CO_\w+)$", line.strip()) + if m: + maps.append(m.group(1)) + engine = "\n".join(l.strip() for _, l in + read_asm(os.path.join(pokered, "engine/events/card_key.asm"))) + for needle in ("cp $18", "cp $24", "cp $5e", "ld a, $3", "ld a, $e"): + if needle not in engine: + util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)") + if n_doors != 22 or len(maps) != 10: + util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}") + return { + "maps": maps, # maps where the engine checks for doors + "doors": doors, # tile coords; block coord = floor(coord/2) + "doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset) + "openBlock": 0x0e, # block written over the door's block + "silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03}, + } + + +def parse_forced_movement(pokered): + """data/maps/force_bike_surf.asm + the Cycling Road engine handling. + + CheckForceBikeOrSurf (engine/overworld/player_state.asm) walks + ForcedBikeOrSurfMaps; on ROUTE_16/ROUTE_18 entries it forces the bike + (wWalkBikeSurfState = 1), on the SEAFOAM_ISLANDS entries it forces + surfing (state 2) and kicks off the map's MOVE_OBJECT current script. + + Cycling Road slope (slopeMaps): JoypadOverworld (home/overworld.asm) + simulates a held PAD_DOWN on ROUTE_17 whenever no d-pad/A/B input is + held (and no trainer battle is starting); DoBikeSpeedup additionally + suppresses the 2x bike speed on ROUTE_17 while UP/LEFT/RIGHT is held. + """ + tiles = {} + path = os.path.join(pokered, "data/maps/force_bike_surf.asm") + for lineno, line in read_asm(path): + m = re.match(r"force_bike_surf\s+(\w+),\s*(\d+),\s*(\d+)$", line.strip()) + if m: + map_id = m.group(1) + mode = "surf" if map_id.startswith("SEAFOAM") else "bike" + tiles.setdefault(map_id, []).append( + {"x": int(m.group(2)), "y": int(m.group(3)), "mode": mode}) + overworld = "\n".join(l.strip() for _, l in + read_asm(os.path.join(pokered, "home/overworld.asm"))) + if not re.search(r"cp ROUTE_17.*?\n(.*\n){0,4}\s*ld a, PAD_DOWN", overworld): + util.die("home/overworld.asm: Cycling Road forced PAD_DOWN not found") + return {"tiles": tiles, "slopeMaps": ["ROUTE_17"]} + + +PAD_TO_DIR = {"PAD_UP": "up", "PAD_DOWN": "down", + "PAD_LEFT": "left", "PAD_RIGHT": "right"} + + +def _parse_script_tables(path): + """Collect labelled dbmapcoord lists and `db PAD_*, n` RLE lists. + + Duplicate labels (e.g. two `.Coords` locals) get a #2, #3... suffix. + RLE lists are returned in source order; like the spinner tables they + are decoded into wSimulatedJoypadStatesEnd and played back with a + decrementing index, so they execute in REVERSE source order. + """ + coords = {} + rle = {} + label = None + seen = {} + for lineno, line in read_asm(path): + s = line.strip() + # local labels may omit the colon; bare instructions that happen to + # match ("ret") just become labels no table refers to + m = re.match(r"\.?(\w+):{0,2}$", s) + if m: + label = m.group(1) + seen[label] = seen.get(label, 0) + 1 + if seen[label] > 1: + label = f"{label}#{seen[label]}" + continue + m = re.match(r"dbmapcoord\s+(\d+),\s*(\d+)$", s) + if m and label: + coords.setdefault(label, []).append( + {"x": int(m.group(1)), "y": int(m.group(2))}) + continue + m = re.match(r"db\s+(PAD_\w+),\s*(\d+)$", s) + if m and label: + rle.setdefault(label, []).append( + {"dir": PAD_TO_DIR[m.group(1)], "count": int(m.group(2))}) + return coords, rle + + +def parse_seafoam(pokered): + """Seafoam Islands surf currents and boulder/hole wiring. + + scripts/SeafoamIslandsB3F.asm / SeafoamIslandsB4F.asm: + * The current tiles are the SEAFOAM entries of ForcedBikeOrSurfMaps; + stepping on one triggers the map's MOVE_OBJECT script, which (unless + the plugging boulders' events are set) decodes an RLE movement list + into simulated joypad presses -- executed in reverse source order, + like the spinner tables. + * B3F additionally sweeps the player from the surf entry at (15,8) + toward the holes while the currents are live, and B4F force-exits + the player upward at the pool's south edge (20..21,16..17). + * Pushing a boulder into an upper floor's hole coords sets an + EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE flag, hides the pushed boulder + object and shows the fallen one on the floor below (which is what + plugs that floor's current); the holes double as dungeon warps. + """ + b3f_coords, b3f_rle = _parse_script_tables( + os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm")) + b4f_coords, b4f_rle = _parse_script_tables( + os.path.join(pokered, "scripts/SeafoamIslandsB4F.asm")) + b2f_coords, _ = _parse_script_tables( + os.path.join(pokered, "scripts/SeafoamIslandsB2F.asm")) + oneF_coords, _ = _parse_script_tables( + os.path.join(pokered, "scripts/SeafoamIslands1F.asm")) + b1f_coords, _ = _parse_script_tables( + os.path.join(pokered, "scripts/SeafoamIslandsB1F.asm")) + + def rev(label, rle_tables): + moves = rle_tables.get(label) + if not moves: + util.die(f"seafoam: missing RLE list {label}") + return list(reversed(moves)) + + def hole_wiring(script_path, holes, lands_at): + """SetEvent / TOGGLE hide+show pairs, in source order.""" + text = "\n".join(l.strip() for _, l in read_asm(script_path)) + events = re.findall(r"SetEvent(?:ReuseHL|AfterBranchReuseHL)?\s+" + r"(EVENT_SEAFOAM\d_BOULDER\d_DOWN_HOLE)", text) + toggles = re.findall(r"ld a, (TOGGLE_SEAFOAM_ISLANDS_\w+)", text) + if len(events) != 2 or len(toggles) != 4 or len(holes) != 2: + util.die(f"seafoam: unexpected hole wiring in {script_path}") + out = [] + for i, hole in enumerate(holes): + out.append({ + "x": hole["x"], "y": hole["y"], + "boulderEvent": events[i], + "hideObject": toggles[2 * i], + "showObject": toggles[2 * i + 1], + "landsAt": lands_at[i], + }) + return out + + # fallen boulder object positions on the floor below + def boulder_objects(objects_file): + out = [] + for lineno, line in read_asm(os.path.join(pokered, objects_file)): + m = re.match(r"object_event\s+(\d+),\s*(\d+),\s*SPRITE_BOULDER", + line.strip()) + if m: + out.append({"x": int(m.group(1)), "y": int(m.group(2))}) + return out + + b3f_text = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm"))) + m = re.search(r"ld a, \[wYCoord\]\s+cp (\d+)\s+ret nz\s+" + r"ld a, \[wXCoord\]\s+cp (\d+)", b3f_text) + if not m: + util.die("SeafoamIslandsB3F.asm: entry-current trigger coords not found") + entry_y, entry_x = int(m.group(1)), int(m.group(2)) + if "cp 18" not in b3f_text or "cp 19" not in b3f_text: + util.die("SeafoamIslandsB3F.asm: current tile x checks changed") + + b3f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB3F.asm") + b4f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB4F.asm") + b1f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB1F.asm") + b2f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB2F.asm") + if len(b3f_boulders) != 6 or len(b4f_boulders) != 2: + util.die("seafoam: unexpected boulder object counts") + # the second .Coords local in B4F is the current-tile trigger list; + # it must agree with the hardcoded current coords below + if b4f_coords.get("Coords#2") != [{"x": 4, "y": 14}, {"x": 5, "y": 14}]: + util.die("SeafoamIslandsB4F.asm: current trigger coords changed") + + seafoam = { + "SEAFOAM_ISLANDS_B3F": { + # both currents die once these two flags are set + "currentsDisabledByEvents": ["EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE"], + "currents": [ + {"x": 18, "y": 7, + "moves": rev("RLEList_StrongCurrentNearLeftBoulder", b3f_rle)}, + {"x": 19, "y": 7, + "moves": rev("RLEList_StrongCurrentNearRightBoulder", b3f_rle)}, + ], + # sweeps the player from the surf entry while currents are live + "entryCurrent": { + "x": entry_x, "y": entry_y, + "moves": rev("RLEList_ForcedSurfingStrongCurrentNearSteps", b3f_rle), + }, + # pushing boulders into these B3F holes plugs the B4F current + "holes": hole_wiring( + os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm"), + b3f_coords.get("Seafoam4HolesCoords", []), + b4f_boulders), + "holeDestination": "SEAFOAM_ISLANDS_B4F", + }, + "SEAFOAM_ISLANDS_B4F": { + "currentsDisabledByEvents": ["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"], + "currents": [ + {"x": 4, "y": 14, + "moves": rev("RLEList_StrongCurrentNearLeftBoulder", b4f_rle)}, + {"x": 5, "y": 14, + "moves": rev("RLEList_StrongCurrentNearRightBoulder", b4f_rle)}, + ], + # while the B3F boulders are NOT both down, standing here forces + # the player up out of the water (2 up-presses on row 17, 1 on 16) + "forcedExit": { + "coords": b4f_coords.get("Coords", []), + "activeUntilEvents": ["EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE"], + }, + }, + } + # the B3F current tiles are plugged by boulders pushed through the B2F + # holes (scripts/SeafoamIslandsB2F.asm Seafoam3HolesCoords); the fallen + # boulders are the last two B3F boulder objects, at (18,6)/(19,6) just + # above the current tiles (data/maps/toggleable_objects.asm maps + # TOGGLE_..._B3F_BOULDER_3/4 to SEAFOAMISLANDSB3F_BOULDER5/6) + seafoam["SEAFOAM_ISLANDS_B3F"]["pluggedByHolesOn"] = { + "map": "SEAFOAM_ISLANDS_B2F", + "holes": hole_wiring( + os.path.join(pokered, "scripts/SeafoamIslandsB2F.asm"), + b2f_coords.get("Seafoam3HolesCoords", []), + b3f_boulders[-2:]), + } + # pushing 1F's boulders into Seafoam1HolesCoords drops them to B1F, and + # B1F's into Seafoam2HolesCoords drops them to B2F (scripts/ + # SeafoamIslands1F.asm / SeafoamIslandsB1F.asm); this is the upper half + # of the same cascade that pluggedByHolesOn wires for B2F->B3F. + seafoam["SEAFOAM_ISLANDS_1F"] = { + "holes": hole_wiring( + os.path.join(pokered, "scripts/SeafoamIslands1F.asm"), + oneF_coords.get("Seafoam1HolesCoords", []), + b1f_boulders), + "holeDestination": "SEAFOAM_ISLANDS_B1F", + } + seafoam["SEAFOAM_ISLANDS_B1F"] = { + "holes": hole_wiring( + os.path.join(pokered, "scripts/SeafoamIslandsB1F.asm"), + b1f_coords.get("Seafoam2HolesCoords", []), + b2f_boulders), + "holeDestination": "SEAFOAM_ISLANDS_B2F", + } + return seafoam + + +def parse_game_corner_poster(pokered): + """scripts/GameCorner.asm: the poster switch that opens the hideout. + + Examining the poster (bg_event TEXT_GAMECORNER_POSTER) runs + GameCornerPosterText, which sets EVENT_FOUND_ROCKET_HIDEOUT and + replaces the tile block at block coords (8,2) -- the top-right corner + of the room -- with the staircase block $43 (ReplaceTileBlock takes + b=Y, c=X block coords). On map load, + GameCornerSetRocketHideoutDoorTile writes the closed block $2a over + the same block while the event is unset. + """ + text = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "scripts/GameCorner.asm"))) + closed = re.search(r"CheckEvent EVENT_FOUND_ROCKET_HIDEOUT\s+ret nz\s+" + r"ld a, (\$\w+)\s+ld \[wNewTileBlockID\], a\s+" + r"lb bc, (\d+), (\d+)", text) + opened = re.search(r"SetEvent EVENT_FOUND_ROCKET_HIDEOUT\s+" + r"ld a, (\$\w+)\s+ld \[wNewTileBlockID\], a\s+" + r"lb bc, (\d+), (\d+)", text) + if not closed or not opened or closed.group(2, 3) != opened.group(2, 3): + util.die("GameCorner.asm: poster block swap not found") + poster = None + for lineno, line in read_asm(os.path.join(pokered, + "data/maps/objects/GameCorner.asm")): + m = re.match(r"bg_event\s+(\d+),\s*(\d+),\s*(TEXT_GAMECORNER_POSTER)", + line.strip()) + if m: + poster = {"x": int(m.group(1)), "y": int(m.group(2))} + if poster is None: + util.die("objects/GameCorner.asm: poster bg_event not found") + return { + "map": "GAME_CORNER", + "x": int(opened.group(3)), # block coords (c = X) + "y": int(opened.group(2)), # block coords (b = Y) + "closedBlock": parse_number(closed.group(1)), + "openBlock": parse_number(opened.group(1)), + "event": "EVENT_FOUND_ROCKET_HIDEOUT", + "posterText": "TEXT_GAMECORNER_POSTER", + "poster": poster, # bg_event tile coords of the poster + } + + +def parse_badge_gates(pokered): + """scripts/Route22Gate.asm and scripts/Route23.asm badge checks. + + Route 22 gate: standing on Route22GateScriptCoords triggers the guard, + who checks BIT_BOULDERBADGE in wObtainedBadges. + + Route 23: Route23DefaultScript matches wYCoord against + Route23GuardsYCoords; row i (top to bottom) is guarded by sprite i+1 + and requires the badge at BadgeTextPointers[N-1-i] (EARTHBADGE at the + northernmost row down to CASCADEBADGE at the southernmost). The + y=35 row only applies at x < 14. Passing a guard sets its + EVENT_PASSED__CHECK flag so it is skipped afterwards. + """ + r22_lines = read_asm(os.path.join(pokered, "scripts/Route22Gate.asm")) + r22_text = "\n".join(l.strip() for _, l in r22_lines) + if "bit BIT_BOULDERBADGE" not in r22_text: + util.die("Route22Gate.asm: BOULDERBADGE check not found") + r22_coords, _ = _parse_script_tables( + os.path.join(pokered, "scripts/Route22Gate.asm")) + coords = r22_coords.get("Route22GateScriptCoords", []) + + ys = [] + badge_ptr_labels = [] + badge_names = {} + text_labels = [] + label = None + in_ys = in_ptrs = in_texts = False + for lineno, line in read_asm(os.path.join(pokered, "scripts/Route23.asm")): + s = line.strip() + m = re.match(r"(\w+)::?\s*$", s) + if m: + label = m.group(1) + in_ys = label == "Route23GuardsYCoords" + in_ptrs = label == "BadgeTextPointers" + continue + if s == "def_text_pointers": + in_texts = True + continue + if in_texts: + m = re.match(r"dw_const\s+(\w+),\s*(\w+)$", s) + if m: + text_labels.append(m.group(1)) + continue + in_texts = False + if in_ys: + m = re.match(r"db\s+(\d+)$", s) + if m: + ys.append(int(m.group(1))) + elif in_ptrs: + m = re.match(r"dw\s+(\w+)$", s) + if m: + badge_ptr_labels.append(m.group(1)) + m = re.match(r'db\s+"(\w+)@"$', s) + if m and label: + badge_names[label] = m.group(1) + + if len(ys) != 7 or len(badge_ptr_labels) != 7: + util.die("Route23.asm: expected 7 guard rows and 7 badge pointers") + guards = [] + for i, y in enumerate(ys): + badge = badge_names.get(badge_ptr_labels[len(ys) - 1 - i]) + if not badge: + util.die(f"Route23.asm: no badge name for row y={y}") + guard = { + "y": y, + "badge": badge, + "event": f"EVENT_PASSED_{badge}_CHECK", + "sprite": i + 1, + "text": text_labels[i] if i < len(text_labels) else None, + } + if i == 0: + guard["maxX"] = 13 # y=35 row is skipped at wXCoord >= 14 + guards.append(guard) + events = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "constants/event_constants.asm"))) + for g in guards: + if g["event"] not in events: + util.die(f"Route23: {g['event']} not in event_constants.asm") + return { + "ROUTE_22_GATE": { + "coords": coords, + "badge": "BOULDERBADGE", + "text": "Route22GateGuardText", + "failText": "Route22GateGuardNoBoulderbadgeText", + "passText": "Route22GateGuardGoRightAheadText", + }, + "ROUTE_23": { + "guards": guards, + "failText": "Route23YouDontHaveTheBadgeYetText", + "passText": "Route23OhThatIsTheBadgeText", + }, + } + + +def parse_preset_names(pokered): + """constants/player_constants.asm: the _RED preset name menus. + + The naming menus (engine/movie/oak_speech/oak_speech2.asm with + data/player/names.asm / names_list.asm) offer NEW NAME plus these + three presets each. + """ + # read_asm resolves the version conditionals (util.ASM_DEFINES), so + # only the _RED name set reaches us + player, rival = [], [] + path = os.path.join(pokered, "constants/player_constants.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r'DEF\s+PLAYERNAME\d\s+EQUS\s+"(\w+)"', s) + if m: + player.append(m.group(1)) + m = re.match(r'DEF\s+RIVALNAME\d\s+EQUS\s+"(\w+)"', s) + if m: + rival.append(m.group(1)) + return {"player": player, "rival": rival, "customOption": "NEW NAME"} + + +def parse_dark_maps(pokered): + """Rock Tunnel darkness (home/overworld.asm). + + Warping into ROCK_TUNNEL_1F sets wMapPalOffset = 6, blacking the + screen out until Flash is used. The offset is only cleared when + leaving through a LAST_MAP warp back outside (or via Flash), so it + persists across the indoor warps into ROCK_TUNNEL_B1F -- both floors + are dark. Flash (engine/menus/start_sub_menus.asm .flash) needs + BOULDERBADGE and simply zeroes wMapPalOffset. + """ + overworld = "\n".join(l.strip() for _, l in + read_asm(os.path.join(pokered, "home/overworld.asm"))) + if not re.search(r"cp ROCK_TUNNEL_1F\s+jr nz, \.notRockTunnel\s+" + r"ld a, \$06\s+ld \[wMapPalOffset\], a", overworld): + util.die("home/overworld.asm: Rock Tunnel darkness code changed") + return { + "maps": ["ROCK_TUNNEL_1F", "ROCK_TUNNEL_B1F"], + "entryMap": "ROCK_TUNNEL_1F", # the only map that sets the offset + "palOffset": 6, + "flashBadge": "BOULDERBADGE", + } + + +def parse_warp_carpets(pokered): + """data/tilesets/warp_carpet_tile_ids.asm + the ExtraWarpCheck routing. + + A warp fires without stepping onto a door/warp tile when the player + stands on the warp square and ExtraWarpCheck (home/overworld.asm) + passes -- either on a collision (CheckWarpsCollision) or on arrival + with the d-pad held (CheckWarpsNoCollision). The check itself is: + * "function 2" (IsWarpTileInFrontOfPlayer): the tile in front of the + player is in the facing direction's warp-carpet list -- used on the + OVERWORLD/SHIP/SHIP_PORT/PLATEAU tilesets plus the four map + exceptions in function2Maps, with SS_ANNE_BOW checking tile $15 + instead of the lists; + * "function 1" (IsPlayerFacingEdgeOfMap) everywhere else (and on + SS_ANNE_3F): the player faces the edge of the map. + """ + dir_labels = {"FacingDownWarpTiles": "down", "FacingUpWarpTiles": "up", + "FacingLeftWarpTiles": "left", "FacingRightWarpTiles": "right"} + carpets = {} + label = None + path = os.path.join(pokered, "data/tilesets/warp_carpet_tile_ids.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"\.(\w+):$", s) + if m: + label = dir_labels.get(m.group(1)) + continue + m = re.match(r"warp_carpet_tiles\s+(.+)$", s) + if m and label: + carpets[label] = [parse_number(a) for a in split_args(m.group(1))] + label = None + if sorted(carpets) != ["down", "left", "right", "up"]: + util.die("warp_carpet_tile_ids.asm: missing facing direction lists") + + overworld = "\n".join(l.strip() for _, l in + read_asm(os.path.join(pokered, "home/overworld.asm"))) + m = re.search(r"ExtraWarpCheck::\n(.*?)\.doBankswitch", overworld, re.S) + if not m: + util.die("home/overworld.asm: ExtraWarpCheck not found") + body = m.group(1) + map_part, tileset_marker, tileset_part = \ + body.partition("ld a, [wCurMapTileset]") + function2_maps = re.findall(r"cp (\w+)\njr z, \.useFunction2", map_part) + edge_maps = re.findall(r"cp (\w+)\njr z, \.useFunction1", map_part) + # `and a` tests for tileset 0 = OVERWORLD + function2_tilesets = ["OVERWORLD"] + re.findall( + r"cp (\w+)\njr z, \.useFunction2", tileset_part) + if not tileset_marker or "and a\njr z, .useFunction2" not in tileset_part \ + or function2_maps != ["ROCKET_HIDEOUT_B1F", "ROCKET_HIDEOUT_B2F", + "ROCKET_HIDEOUT_B4F", "ROCK_TUNNEL_1F"] \ + or edge_maps != ["SS_ANNE_3F"] \ + or function2_tilesets != ["OVERWORLD", "SHIP", "SHIP_PORT", "PLATEAU"]: + util.die("home/overworld.asm: ExtraWarpCheck routing changed") + + player_state = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "engine/overworld/player_state.asm"))) + m = re.search(r"IsSSAnneBowWarpTileInFrontOfPlayer:\n" + r"ld a, \[wTileInFrontOfPlayer\]\ncp (\$\w+)", player_state) + if not m: + util.die("player_state.asm: SS Anne bow warp tile check not found") + return { + "tiles": carpets, # facing dir -> tile-in-front ids + "function2Maps": function2_maps, + "edgeMaps": edge_maps, # tileset would say carpet; map says edge + "function2Tilesets": function2_tilesets, + "ssAnneBow": {"map": "SS_ANNE_BOW", "tile": parse_number(m.group(1))}, + } + + +def parse_dungeon_transition_maps(pokered): + """data/maps/dungeon_maps.asm: the battle-transition dungeon lists. + + GetBattleTransitionID_IsDungeonMap checks wCurMap against the singles + in DungeonMaps1 and the inclusive id ranges in DungeonMaps2 (the lists + famously miss several dungeons -- kept as-is on purpose). + """ + singles, ranges = [], [] + section = None + path = os.path.join(pokered, "data/maps/dungeon_maps.asm") + for lineno, line in read_asm(path): + s = line.strip() + if s == "DungeonMaps1:": + section = singles + continue + if s == "DungeonMaps2:": + section = ranges + continue + m = re.match(r"db\s+(\w+),\s*(\w+)$", s) + if m and section is ranges: + ranges.append({"first": m.group(1), "last": m.group(2)}) + continue + m = re.match(r"db\s+(\w+)$", s) + if m and m.group(1) != "-1" and section is singles: + singles.append(m.group(1)) + return {"maps": singles, "ranges": ranges} + + +def parse_bike_riding(pokered): + """data/tilesets/bike_riding_tilesets.asm + IsBikeRidingAllowed. + + The bike may be ridden on maps whose tileset is in the list, plus the + ROUTE_23 / INDIGO_PLATEAU map exceptions (home/overworld.asm). + """ + tilesets = [] + path = os.path.join(pokered, "data/tilesets/bike_riding_tilesets.asm") + for lineno, line in read_asm(path): + m = re.match(r"db\s+(\w+)$", line.strip()) + if m and m.group(1) != "-1": + tilesets.append(m.group(1)) + overworld = "\n".join(l.strip() for _, l in + read_asm(os.path.join(pokered, "home/overworld.asm"))) + body = overworld[overworld.find("IsBikeRidingAllowed::"):] + body = body[:body.find("ld a, [wCurMapTileset]")] # map checks come first + maps = re.findall(r"cp (\w+)\njr z, \.allowed", body) + if maps != ["ROUTE_23", "INDIGO_PLATEAU"]: + util.die("home/overworld.asm: IsBikeRidingAllowed map exceptions changed") + return {"tilesets": tilesets, "maps": maps} + + +def parse_indoor_encounters(pokered): + """engine/battle/wild_encounters.asm indoor rule. + + On maps with id >= FIRST_INDOOR_MAP whose tileset is not FOREST, every + walkable tile rolls grass-table encounters (caves, towers, the Mansion). + """ + wild = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "engine/battle/wild_encounters.asm"))) + if not re.search(r"cp FIRST_INDOOR_MAP.*\njr c, \.CantEncounter2\n" + r"ld a, \[wCurMapTileset\]\ncp FOREST", wild): + util.die("wild_encounters.asm: indoor encounter rule changed") + _, first_indoor, _ = parse_map_constants(pokered) + return {"firstIndoorMap": first_indoor, "excludedTileset": "FOREST"} + + +# The three maps with spinner arrow tiles keep their movement tables in +# their map scripts (map_coord_movement x, y -> RLE list; each list is +# read backwards from the terminator -- see scripts/RocketHideoutB2F.asm). +SPINNER_SCRIPTS = { + "VIRIDIAN_GYM": "ViridianGym.asm", + "ROCKET_HIDEOUT_B2F": "RocketHideoutB2F.asm", + "ROCKET_HIDEOUT_B3F": "RocketHideoutB3F.asm", +} + +PAD_DIRS = {"PAD_UP": "up", "PAD_DOWN": "down", + "PAD_LEFT": "left", "PAD_RIGHT": "right"} + + +def parse_spinners(pokered): + spinners = {} + for map_id, fname in sorted(SPINNER_SCRIPTS.items()): + path = os.path.join(pokered, "scripts", fname) + table = [] # (x, y, label) + lists = {} # label -> [(dir, count)] in source order + current_list = None + in_table = False + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"map_coord_movement\s+(\d+),\s*(\d+),\s*(\w+)", s) + if m: + in_table = True + table.append((int(m.group(1)), int(m.group(2)), m.group(3))) + continue + m = re.match(r"(\w*ArrowMovement\w*):", s) + if m: + current_list = [] + lists[m.group(1)] = current_list + continue + m = re.match(r"db\s+(PAD_\w+),\s*(\d+)", s) + if m and current_list is not None: + current_list.append((PAD_DIRS[m.group(1)], int(m.group(2)))) + continue + if s.startswith("db -1") and current_list is not None: + current_list = None + entries = [] + for x, y, label in table: + moves = lists.get(label) + if moves is None: + util.die(f"spinners: {fname}: missing movement list {label}") + # lists execute from the terminator backwards + entries.append({"x": x, "y": y, + "moves": [{"dir": d, "count": c} + for d, c in reversed(moves)]}) + if not entries: + util.die(f"spinners: {fname}: no arrow tile table found") + spinners[map_id] = entries + return spinners + + +def parse_map_constants(pokered): + """constants/map_constants.asm: ordered map ids + indoor group markers. + + Returns (maps, first_indoor, groups) where groups is an ordered list of + (group_name, boundary): INDOORGROUP_ equals the map id AFTER the + group's last map (end_indoor_group defines it as const_value). + """ + maps = [] + groups = [] + first_indoor = None + path = os.path.join(pokered, "constants/map_constants.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"map_const\s+(\w+),", s) + if m: + maps.append(m.group(1)) + continue + m = re.match(r"end_indoor_group\s+(\w+)$", s) + if m: + groups.append((m.group(1), len(maps))) + continue + if re.match(r"DEF\s+FIRST_INDOOR_MAP\b", s): + first_indoor = len(maps) + if not maps or first_indoor is None or not groups: + util.die("map_constants.asm: could not parse map ids/indoor groups") + return maps, first_indoor, groups + + +def parse_town_map(pokered): + """data/maps/town_map_entries.asm (+ names.asm, town_map_order.asm). + + Every map gets a town-map position and display name: + * outdoor maps (id < FIRST_INDOOR_MAP) index ExternalMapEntries + directly; `outdoor_map x, y, Name` stores `dn y, x` + name pointer. + * indoor maps are looked up in InternalMapEntries by LoadTownMapEntry + (engine/items/town_map.asm): the first entry whose INDOORGROUP_* + boundary exceeds the map id wins, so one entry covers a contiguous + id range (`indoor_map GROUP, x, y, Name`). + Coordinates are a 16x16 nybble grid; the cursor/player marker is drawn + at pixel (x*8 + 24, y*8 + 24) in OAM coords (TownMapCoordsToOAMCoords), + i.e. an 8px grid over the 160x144 map screen. Routes only get a single + x,y point in this data (no spans). TownMapOrder is the SELECT-cursor + order when scrolling through locations. + """ + maps, first_indoor, groups = parse_map_constants(pokered) + + names = {} + for lineno, line in read_asm(os.path.join(pokered, "data/maps/names.asm")): + m = re.match(r'(\w+):\s*db\s+"([^"]*)"', line.strip()) + if m: + names[m.group(1)] = text.decode_string(m.group(2), lineno, + "data/maps/names.asm") + + external = [] # (x, y, name label), index = map id + internal = [] # (group, x, y, name label) + path = os.path.join(pokered, "data/maps/town_map_entries.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"outdoor_map\s+(\d+),\s*(\d+),\s*(\w+)$", s) + if m: + external.append((int(m.group(1)), int(m.group(2)), m.group(3))) + continue + m = re.match(r"indoor_map\s+(\w+),\s*(\d+),\s*(\d+),\s*(\w+)$", s) + if m: + internal.append((m.group(1), int(m.group(2)), int(m.group(3)), + m.group(4))) + if len(external) != first_indoor: + util.die(f"town map: {len(external)} outdoor entries != FIRST_INDOOR_MAP " + f"({first_indoor})") + if [g for g, _ in groups] != [e[0] for e in internal]: + util.die("town map: indoor_map groups do not match map_constants.asm") + + def entry(x, y, label): + if label not in names or not (0 <= x <= 15 and 0 <= y <= 15): + util.die(f"town map: bad entry {x},{y},{label}") + return {"x": x, "y": y, "name": names[label]} + + locations = {} + for map_id, (x, y, label) in zip(maps[:first_indoor], external): + if not map_id.startswith("UNUSED_MAP"): + locations[map_id] = entry(x, y, label) + prev = first_indoor + for (group, x, y, label), (_, boundary) in zip(internal, groups): + if boundary <= prev or boundary > len(maps): + util.die(f"town map: group {group} boundary {boundary} out of order") + for map_id in maps[prev:boundary]: + if not map_id.startswith("UNUSED_MAP"): + locations[map_id] = entry(x, y, label) + prev = boundary + if prev != len(maps): + util.die("town map: indoor groups do not cover all maps") + + cursor_order = [] + started = False + for lineno, line in read_asm(os.path.join(pokered, "data/maps/town_map_order.asm")): + s = line.strip() + if s == "TownMapOrder:": + started = True + continue + m = re.match(r"db\s+(\w+)$", s) + if started and m: + cursor_order.append(m.group(1)) + for map_id in cursor_order: + if map_id not in locations: + util.die(f"town map: cursor order map {map_id} has no entry") + + return { + "locations": locations, # map id -> {x, y, name} (16x16 grid) + "cursorOrder": cursor_order, + "gridPixelSize": 8, # marker pixel = coord*8 + 24 (OAM coords) + } + + +def parse_credits(pokered): + """The end-credits roll (engine/movie/credits.asm Credits). + + data/credits/credits_order.asm is a byte stream: CRED_* string ids + accumulate lines on the current screen, and CRED_TEXT / CRED_TEXT_FADE + / CRED_TEXT_MON / CRED_TEXT_FADE_MON terminate it (FADE = palette + fade-in, MON = scroll in the next CreditsMons entry afterwards). + CRED_COPYRIGHT draws the copyright logo (the `title.copyright` asset) + on the current screen; CRED_THE_END ends the roll and shows the + the_end graphic. Each string in credits_text.asm starts with a signed + db: the x offset from column 9 where the line is placed (lines are + printed at rows 6, 8, 10, ... of the screen). + """ + cred_names = util.parse_const_block( + os.path.join(pokered, "constants/credits_constants.asm"), + stop_at="NUM_CRED_STRINGS") + + # CreditsTextPointers: CRED_* value -> string label + pointers = [] + strings = {} + skip = False + label = None + path = os.path.join(pokered, "data/credits/credits_text.asm") + for lineno, line in read_asm(path): + s = line.strip() + if re.match(r"IF\s+DEF\(_RED\)", s): + continue + if re.match(r"IF\s+DEF\(", s): + skip = True + continue + if s == "ENDC": + skip = False + continue + if skip: + continue + m = re.match(r"dw\s+(\w+)$", s) + if m: + pointers.append(m.group(1)) + continue + m = re.match(r"(\w+):$", s) + if m and m.group(1) != "CreditsTextPointers": + label = m.group(1) + continue + 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 + "text": text.decode_string(m.group(2), lineno, + "data/credits/credits_text.asm"), + } + 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") + + mons = [] + for lineno, line in read_asm(os.path.join(pokered, "data/credits/credits_mons.asm")): + m = re.match(r"db\s+(\w+)$", line.strip()) + if m: + mons.append(m.group(1)) + + cred_index = {name: i for i, name in enumerate(cred_names)} + commands = {"CRED_TEXT": (False, False), "CRED_TEXT_FADE": (True, False), + "CRED_TEXT_MON": (False, True), "CRED_TEXT_FADE_MON": (True, True)} + screens = [] + current = {"lines": []} + the_end_seen = False + mon_count = 0 + path = os.path.join(pokered, "data/credits/credits_order.asm") + for lineno, line in read_asm(path): + m = re.match(r"db\s+(.+)$", line.strip()) + if not m: + continue + for tok in split_args(m.group(1)): + if the_end_seen: + util.die("credits_order.asm: data after CRED_THE_END") + if tok in commands: + fade, mon = commands[tok] + current["fade"] = fade + if mon: + if mon_count >= len(mons): + util.die("credits_order.asm: more MON screens than CreditsMons") + current["mon"] = mons[mon_count] + mon_count += 1 + screens.append(current) + current = {"lines": []} + elif tok == "CRED_COPYRIGHT": + current["copyright"] = True + elif tok == "CRED_THE_END": + the_end_seen = True + elif tok in cred_index: + 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])) + else: + util.die(f"credits_order.asm:{lineno}: unknown token {tok}") + if not the_end_seen or current["lines"]: + util.die("credits_order.asm: missing CRED_THE_END terminator") + if mon_count != len(mons): + util.die(f"credits: {len(mons)} CreditsMons but {mon_count} MON screens") + return { + "screens": screens, # {lines = {{text, column}...}, fade, mon?, copyright?} + "mons": mons, # CreditsMons, consumed in order by MON screens + } + + +def parse_old_man_battle(pokered): + """scripts/ViridianCity.asm: the old man's catch-demo wild battle. + + ViridianCityOldManStartCatchTrainingScript sets wBattleType = + BATTLE_TYPE_OLD_MAN, wCurEnemyLevel = 5 and wCurOpponent = WEEDLE; + engine/battle/core.asm special-cases that battle type (the player's + name is temporarily swapped for OLD MAN, the source of the MissingNo. + glitch). The end script shows the "you need to weaken the target" + text afterwards. + """ + script = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "scripts/ViridianCity.asm"))) + m = re.search(r"ld a, BATTLE_TYPE_OLD_MAN\s+ld \[wBattleType\], a\s+" + r"ld a, (\d+)\s+ld \[wCurEnemyLevel\], a\s+" + r"ld a, (\w+)\s+ld \[wCurOpponent\], a", script) + if not m: + util.die("ViridianCity.asm: old man battle setup not found") + core = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "engine/battle/core.asm"))) + if "ASSERT BATTLE_TYPE_OLD_MAN == 1" not in core: + util.die("core.asm: old man battle type handling changed") + texts = {} + for text_id, key in (("TEXT_VIRIDIANCITY_OLD_MAN", "text"), + ("TEXT_VIRIDIANCITY_OLD_MAN_YOU_NEED_TO_WEAKEN_THE_TARGET", + "afterText")): + tm = re.search(rf"dw_const\s+(\w+),\s+{text_id}$", script, re.M) + if not tm: + util.die(f"ViridianCity.asm: {text_id} not found") + texts[key] = tm.group(1) + return { + "map": "VIRIDIAN_CITY", + "species": m.group(2), + "level": int(m.group(1)), + "battleType": "BATTLE_TYPE_OLD_MAN", + "text": texts["text"], # ViridianCityOldManText + "afterText": texts["afterText"], # ...YouNeedToWeakenTheTargetText + } + + +def parse_coin_purchases(pokered): + """scripts/GameCorner.asm GameCornerClerk1Text: coins for money. + + The only purchase the script implements is 50 coins for ¥1000 (BCD: + hMoney = 00 10 00, hCoins = 00 50); text/GameCorner.asm's dialogue + ("It's ¥1000 for 50 coins") matches. There is NO 500-coin/¥10000 + option in Red. Requires the COIN_CASE and room for the coins + (Has9990Coins must carry). + """ + script = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "scripts/GameCorner.asm"))) + buys = re.findall( + r"xor a\s+ldh \[hMoney\], a\s+ldh \[hMoney \+ 2\], a\s+" + r"ld a, \$(\d+)\s+ldh \[hMoney \+ 1\], a\s+" + r"ld hl, hMoney \+ 2\s+ld de, wPlayerMoney \+ 2\s+ld c, \$3\s+" + r"predef SubBCDPredef\s+" + r"xor a\s+ldh \[hUnusedCoinsByte\], a\s+ldh \[hCoins\], a\s+" + r"ld a, \$(\d+)\s+ldh \[hCoins \+ 1\], a", script) + if len(buys) != 1: + util.die(f"GameCorner.asm: expected exactly 1 coin purchase, got {len(buys)}") + money_mid, coins_low = buys[0] + # BCD: hMoney = 00 00, hCoins = 00 + price = int(f"00{money_mid}00") + coins = int(f"00{coins_low}") + dialogue = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "text/GameCorner.asm"))) + if f"¥{price} for {coins}" not in dialogue: + util.die("GameCorner text/script coin purchase mismatch") + return [{"coins": coins, "price": price}] + + +def parse_pc_item_cap(pokered): + """constants/menu_constants.asm PC_ITEM_CAPACITY (wNumBoxItems size). + + engine/items/inventory.asm AddItemToInventory uses it as the cap when + hl = wNumBoxItems (`ld d, PC_ITEM_CAPACITY`). + """ + cap = None + path = os.path.join(pokered, "constants/menu_constants.asm") + for lineno, line in read_asm(path): + m = re.match(r"DEF\s+PC_ITEM_CAPACITY\s+EQU\s+(\d+)$", line.strip()) + if m: + cap = int(m.group(1)) + if cap is None: + util.die("menu_constants.asm: PC_ITEM_CAPACITY not found") + inventory = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "engine/items/inventory.asm"))) + if "ld d, PC_ITEM_CAPACITY" not in inventory: + util.die("inventory.asm: PC_ITEM_CAPACITY use not found") + return cap + + +def extract(pokered, out_dir): + ledges = [] + for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/ledge_tiles.asm")): + m = re.match(r"db\s+(SPRITE_FACING_\w+),\s*(\$\w+),\s*(\$\w+),\s*PAD_(\w+)", line.strip()) + if m: + ledges.append({ + "facing": DIRS[m.group(1)], + "standingTile": parse_number(m.group(2)), + "ledgeTile": parse_number(m.group(3)), + "input": m.group(4).lower(), + }) + + cut_trees = [] + for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/cut_tree_blocks.asm")): + m = re.match(r"db\s+(\$\w+),\s*(\$\w+)$", line.strip()) + if m: + cut_trees.append({"before": parse_number(m.group(1)), + "after": parse_number(m.group(2))}) + + water = [] + started = False + for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/water_tilesets.asm")): + s = line.strip() + if s.startswith("WaterTilesets"): + started = True + continue + m = re.match(r"db\s+(\w+)$", s) + if started and m and m.group(1) != "-1": + water.append(m.group(1)) + + # tile-pair collisions (data/tilesets/pair_collision_tile_ids.asm): + # you may not cross between tile1 and tile2 in the given tileset -- + # elevation edges in caves and the forest. Land pairs apply while + # walking; water pairs while surfing. + tile_pairs = {"land": [], "water": []} + group = None + for lineno, line in read_asm( + os.path.join(pokered, "data/tilesets/pair_collision_tile_ids.asm")): + s = line.strip() + if s.startswith("TilePairCollisionsLand"): + group = "land" + continue + if s.startswith("TilePairCollisionsWater"): + group = "water" + continue + m = re.match(r"db\s+(\w+),\s*(\$\w+),\s*(\$\w+)", s) + if group and m: + tile_pairs[group].append({ + "tileset": m.group(1), + "a": parse_number(m.group(2)), + "b": parse_number(m.group(3)), + }) + + trades = parse_trades(pokered) + fly_warps, fly_order = parse_fly_warps(pokered) + super_rod = parse_super_rod(pokered) + hidden_items, hidden_coins, slot_machines, extras = parse_hidden_events(pokered) + slot_wheels = parse_slot_wheels(pokered) + spinners = parse_spinners(pokered) + + # resolve bench guy texts (map -> text label + facing the engine checks) + bench_texts = parse_bench_guy_texts(pokered) + for map_id, guys in extras["benchGuys"].items(): + entry = bench_texts.get(map_id) + for guy in guys: + if entry: + guy["text"] = entry["text"] + guy["textFacing"] = entry["facing"] + hidden_extras = { + "pcTiles": extras["pcTiles"], + "benchGuys": extras["benchGuys"], + "gymStatues": extras["gymStatues"], + "trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]), + } + + card_key_doors = parse_card_key_doors(pokered) + forced_movement = parse_forced_movement(pokered) + seafoam = parse_seafoam(pokered) + game_corner_poster = parse_game_corner_poster(pokered) + badge_gates = parse_badge_gates(pokered) + preset_names = parse_preset_names(pokered) + dark_maps = parse_dark_maps(pokered) + warp_carpets = parse_warp_carpets(pokered) + dungeon_transition_maps = parse_dungeon_transition_maps(pokered) + bike_riding = parse_bike_riding(pokered) + indoor_encounters = parse_indoor_encounters(pokered) + + town_map = parse_town_map(pokered) + credits = parse_credits(pokered) + old_man_battle = parse_old_man_battle(pokered) + coin_purchases = parse_coin_purchases(pokered) + pc_item_cap = parse_pc_item_cap(pokered) + + # title screen assets live next to the other generated assets; build_data + # passes only the data dir, so derive assets/generated from it + assets_dir = os.path.normpath( + os.path.join(out_dir, os.pardir, os.pardir, "assets", "generated")) + title = gfx.extract_title(pokered, assets_dir) + intro = gfx.extract_intro(pokered, assets_dir) + slot_symbols = gfx.extract_slots(pokered, assets_dir) + emotion_bubbles = gfx.extract_emotes(pokered, assets_dir) + oak_speech = gfx.extract_oak_speech(pokered, assets_dir) + overworld_fx = gfx.extract_overworld_fx(pokered, assets_dir) + credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir) + battle_hud = gfx.extract_battle_hud(pokered, assets_dir) + town_map["background"] = gfx.extract_town_map_bg(pokered, assets_dir) + + if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \ + or "PALLET_TOWN" not in fly_warps: + util.die("field extraction sanity check failed") + if "VIRIDIAN_FOREST" not in hidden_items or len(slot_wheels) != 3 \ + or "VIRIDIAN_GYM" not in spinners: + util.die("hidden events / slots / spinner extraction sanity check failed") + if "SILPH_CO_2F" not in card_key_doors["doors"] \ + or len(card_key_doors["doors"]["SILPH_CO_11F"]) != 2: + util.die("card key door extraction sanity check failed") + if len(hidden_extras["trashCans"]["cans"]) != 15 \ + or len(hidden_extras["pcTiles"]) < 10 \ + or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \ + or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER", + [{}])[0].get("text") is None: + util.die("hidden extras extraction sanity check failed") + if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18", + "SEAFOAM_ISLANDS_B3F", + "SEAFOAM_ISLANDS_B4F"] \ + or forced_movement["slopeMaps"] != ["ROUTE_17"]: + util.die("forced movement extraction sanity check failed") + for map_id in ("SEAFOAM_ISLANDS_B3F", "SEAFOAM_ISLANDS_B4F"): + if len(seafoam[map_id]["currents"]) != 2 \ + or not all(c["moves"] for c in seafoam[map_id]["currents"]): + util.die("seafoam current extraction sanity check failed") + if len(seafoam["SEAFOAM_ISLANDS_B3F"]["holes"]) != 2 \ + or len(seafoam["SEAFOAM_ISLANDS_B4F"]["forcedExit"]["coords"]) != 4: + util.die("seafoam hole/exit extraction sanity check failed") + if len(seafoam["SEAFOAM_ISLANDS_1F"]["holes"]) != 2 \ + or len(seafoam["SEAFOAM_ISLANDS_B1F"]["holes"]) != 2: + util.die("seafoam 1F/B1F hole extraction sanity check failed") + if game_corner_poster["closedBlock"] == game_corner_poster["openBlock"] \ + or game_corner_poster["closedBlock"] != 0x2A \ + or game_corner_poster["openBlock"] != 0x43: + util.die("Game Corner poster extraction sanity check failed") + if len(badge_gates["ROUTE_23"]["guards"]) != 7 \ + or badge_gates["ROUTE_23"]["guards"][0]["badge"] != "EARTHBADGE" \ + or badge_gates["ROUTE_23"]["guards"][-1]["badge"] != "CASCADEBADGE" \ + or len(badge_gates["ROUTE_22_GATE"]["coords"]) != 2: + util.die("badge gate extraction sanity check failed") + if "RED" not in preset_names["player"] or "BLUE" not in preset_names["rival"] \ + or len(preset_names["player"]) != 3 or len(preset_names["rival"]) != 3: + util.die("preset name extraction sanity check failed") + if "ROCK_TUNNEL_1F" not in dark_maps["maps"]: + util.die("dark map extraction sanity check failed") + if warp_carpets["tiles"]["down"] != [0x01, 0x12, 0x17, 0x3D, 0x04, 0x18, 0x33] \ + or warp_carpets["tiles"]["up"] != [0x01, 0x5C] \ + or warp_carpets["tiles"]["left"] != [0x1A, 0x4B] \ + or warp_carpets["tiles"]["right"] != [0x0F, 0x4E] \ + or warp_carpets["ssAnneBow"]["tile"] != 0x15: + util.die("warp carpet extraction sanity check failed") + if dungeon_transition_maps["maps"] != ["VIRIDIAN_FOREST", "ROCK_TUNNEL_1F", + "SEAFOAM_ISLANDS_1F", "ROCK_TUNNEL_B1F"] \ + or len(dungeon_transition_maps["ranges"]) != 4 \ + or dungeon_transition_maps["ranges"][0] != {"first": "MT_MOON_1F", + "last": "MT_MOON_B2F"}: + util.die("dungeon transition map extraction sanity check failed") + if bike_riding["tilesets"] != ["OVERWORLD", "FOREST", "UNDERGROUND", + "SHIP_PORT", "CAVERN"]: + util.die("bike riding tileset extraction sanity check failed") + if indoor_encounters["firstIndoorMap"] != 0x25: + util.die("indoor encounter boundary sanity check failed") + if len(title) != 5 or any(not v["width"] for v in title.values()) \ + or (title["gamefreakInc"]["width"], + title["gamefreakInc"]["height"]) != (72, 8): + util.die("title asset extraction sanity check failed") + if any((intro["gengar"][f]["width"], intro["gengar"][f]["height"]) + != (56, 56) for f in ("frame1", "frame2", "frame3")) \ + or any((intro["nidorino"][f]["width"], intro["nidorino"][f]["height"]) + != (48, 48) for f in ("frame1", "frame2", "frame3")) \ + or (intro["fallingStar"]["width"], intro["bigStar"]["width"], + intro["gamefreakText"]["width"]) != (8, 16, 80): + util.die("intro asset extraction sanity check failed") + if town_map["locations"].get("PALLET_TOWN") != {"x": 2, "y": 11, + "name": "PALLET TOWN"} \ + or town_map["locations"].get("CERULEAN_CAVE_1F", {}).get("name") != "CERULEAN CAVE" \ + or len(town_map["cursorOrder"]) != 47 \ + or town_map["cursorOrder"][0] != "PALLET_TOWN": + util.die("town map extraction sanity check failed") + if len(credits["screens"]) != 35 or len(credits["mons"]) != 15 \ + or credits["screens"][0]["lines"][1]["text"] != "RED VERSION STAFF" \ + or credits["screens"][1]["lines"] != [{"column": 6, "text": "DIRECTOR"}, + {"column": 3, "text": "SATOSHI TAJIRI"}] \ + or not credits["screens"][-1].get("copyright") \ + or credits["mons"][0] != "VENUSAUR": + util.die("credits extraction sanity check failed") + wheel_symbols = {sym for wheel in slot_wheels for sym in wheel} + if wheel_symbols != set(slot_symbols["symbols"]) \ + or slot_symbols["symbols"]["7"]["tiles"] != 0x0200: + util.die("slot symbol extraction sanity check failed") + if [b["name"] for b in emotion_bubbles["bubbles"]] != \ + ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE"]: + util.die("emotion bubble extraction sanity check failed") + if old_man_battle["species"] != "WEEDLE" or old_man_battle["level"] != 5: + util.die("old man battle extraction sanity check failed") + if coin_purchases != [{"coins": 50, "price": 1000}]: + util.die("coin purchase extraction sanity check failed") + if pc_item_cap != 50: + util.die("PC item capacity sanity check failed") + + data = {"ledges": ledges, "cutTreeSwaps": cut_trees, + "waterTilesets": water, "tilePairs": tile_pairs, + "trades": trades, + "flyWarps": fly_warps, "flyOrder": fly_order, + "superRod": super_rod, + "hiddenItems": hidden_items, "hiddenCoins": hidden_coins, + "slotMachines": slot_machines, "slotWheels": slot_wheels, + "spinners": spinners, + "cardKeyDoors": card_key_doors, + "hiddenExtras": hidden_extras, + "forcedMovement": forced_movement, + "seafoam": seafoam, + "gameCornerPoster": game_corner_poster, + "badgeGates": badge_gates, + "presetNames": preset_names, + "darkMaps": dark_maps, + "warpCarpets": warp_carpets, + "dungeonTransitionMaps": dungeon_transition_maps, + "bikeRiding": bike_riding, + "indoorEncounters": indoor_encounters, + "title": title, + "intro": intro, + "battleHud": battle_hud, + "townMap": town_map, + "credits": credits, + "slotSymbols": slot_symbols, + "emotionBubbles": emotion_bubbles, + "oakSpeech": oak_speech, + "overworldFx": overworld_fx, + "oldManBattle": old_man_battle, + "coinPurchases": coin_purchases, + "pcItemCap": pc_item_cap, + "source": "data/tilesets/{ledge_tiles,cut_tree_blocks,water_tilesets}.asm,\n" + "data/events/{trades,hidden_events,slot_machine_wheels,\n" + "bench_guys,card_key_coords,card_key_maps}.asm,\n" + "data/maps/{special_warps,force_bike_surf}.asm,\n" + "engine/events/card_key.asm,\n" + "engine/events/hidden_events/vermilion_gym_trash.asm,\n" + "scripts/{SeafoamIslandsB2F,SeafoamIslandsB3F,SeafoamIslandsB4F,\n" + "GameCorner,Route22Gate,Route23}.asm,\n" + "constants/player_constants.asm, home/overworld.asm,\n" + "gfx/title/*.png + gfx/splash/copyright.png,\n" + "gfx/splash/*.png + gfx/intro/* + gfx/battle/move_anim_1.png\n" + "+ engine/movie/{splash,intro}.asm,\n" + "scripts/*.asm spinner tables,\n" + "data/maps/{town_map_entries,town_map_order,names}.asm\n" + "+ constants/map_constants.asm + engine/items/town_map.asm,\n" + "data/credits/{credits_order,credits_text,credits_mons}.asm\n" + "+ constants/credits_constants.asm + engine/movie/credits.asm\n" + "+ gfx/credits/the_end.png,\n" + "constants/script_constants.asm (SLOTS*, *_BUBBLE)\n" + "+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\n" + "+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n" + "scripts/ViridianCity.asm + engine/battle/core.asm,\n" + "scripts/GameCorner.asm + text/GameCorner.asm,\n" + "constants/menu_constants.asm + engine/items/inventory.asm,\n" + "data/tilesets/{warp_carpet_tile_ids,bike_riding_tilesets}.asm,\n" + "data/maps/dungeon_maps.asm,\n" + "engine/overworld/player_state.asm,\n" + "engine/battle/wild_encounters.asm"} + util.write_lua(os.path.join(out_dir, "field.lua"), data, + header="Ledges, Cut trees, water, trades, Fly spots, hidden items,\n" + "slot machines, spinner arrow tiles, card key doors,\n" + "hidden event extras (PCs/bench guys/statues/trash cans),\n" + "forced bike/surf tiles, Seafoam currents, Game Corner\n" + "poster, badge gates, preset names, dark maps, title assets,\n" + "intro movie assets, town map, credits, slot symbols,\n" + "emotion bubbles,\n" + "old man catch demo, coin purchases, PC item capacity.") + return data diff --git a/tools/extract/font.py b/tools/extract/font.py new file mode 100644 index 00000000..a7ff174b --- /dev/null +++ b/tools/extract/font.py @@ -0,0 +1,110 @@ +"""Extract the text font and character map. + +Sources: + gfx/font/font.png -> glyphs for codes $80-$FF (16 per row, 8x8) + gfx/font/font_extra.png -> glyphs for codes $60-$7F (border tiles etc.) + constants/charmap.asm -> printable char/token -> glyph code + +Output: + assets/generated/fonts/font.png (black ink on transparent) + assets/generated/fonts/font_extra.png + data/generated/font.lua (charmap sorted longest-first) +""" + +import os +import re + +from PIL import Image + +from . import util +from .util import parse_number, read_asm, warn + +# Tokens the runtime substitutes rather than draws. +RUNTIME_TOKENS = {"", "", "", "<_CONT>", "", "", + "", "@", "", "", "", "#", "", + "<……>", "", "", "", "", "", + "", "", "", ""} + + +def _ink(src): + """1bpp/2bpp font PNG -> black ink with transparent background.""" + im = Image.open(src).convert("L") + out = Image.new("RGBA", im.size, (0, 0, 0, 0)) + sp, dp = im.load(), out.load() + for y in range(im.size[1]): + for x in range(im.size[0]): + if sp[x, y] < 128: + dp[x, y] = (0, 0, 0, 255) + return out + + +def convert_font(src, dst, patches=None): + """Convert a font sheet; patches = [(png, src_tile, dst_tile), ...] + overwrite 8x8 tiles with tiles taken from another sheet.""" + out = _ink(src) + per_row = out.size[0] // 8 + for png, src_tile, dst_tile in patches or []: + pat = _ink(png) + pr = pat.size[0] // 8 + sx, sy = (src_tile % pr) * 8, (src_tile // pr) * 8 + dx, dy = (dst_tile % per_row) * 8, (dst_tile // per_row) * 8 + out.paste(pat.crop((sx, sy, sx + 8, sy + 8)), (dx, dy)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + out.save(dst, optimize=True) + return out.size + + +def parse_charmap(pokered): + """charmap.asm entries for the main font range ($60-$FF).""" + entries = [] + seen = set() + for lineno, line in read_asm(os.path.join(pokered, "constants/charmap.asm")): + m = re.match(r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$\w+)', line.strip()) + if not m: + continue + seq = m.group(1).replace('\\"', '"') + code = parse_number(m.group(2)) + if seq in seen: + continue # later blocks redefine codes for other gfx files + seen.add(seq) + if seq in RUNTIME_TOKENS: + continue + if 0x60 <= code <= 0xFF: + entries.append({"seq": seq, "code": code}) + # ASCII double quote has no charmap.asm entry (the original writes the + # curly “/” glyphs, and the dex height's inch mark is ″); alias it to + # the closing-quote glyph $73 so hand-written port text renders a quote + # instead of a blank + warning. + entries.append({"seq": '"', "code": 0x73}) + # longest-first so the renderer can greedily match 'd 'l 's etc. + entries.sort(key=lambda e: (-len(e["seq"]), e["seq"])) + return entries + + +def extract(pokered, out_dir, assets_dir): + fonts_dir = os.path.join(assets_dir, "fonts") + main = convert_font(os.path.join(pokered, "gfx/font/font.png"), + os.path.join(fonts_dir, "font.png")) + # The dex screen loads ′/″ over vChars2 tiles $60/$61 (engine/gfx/ + # load_pokedex_tiles.asm; charmap.asm maps ′->$60 ″->$61 for + # gfx/pokedex/pokedex.png). font_extra.png's own $60/$61 are the + # unused /, so bake the dex glyphs into those slots. + pokedex_png = os.path.join(pokered, "gfx/pokedex/pokedex.png") + extra = convert_font(os.path.join(pokered, "gfx/font/font_extra.png"), + os.path.join(fonts_dir, "font_extra.png"), + patches=[(pokedex_png, 0, 0x60 - 0x60), + (pokedex_png, 1, 0x61 - 0x60)]) + charmap = parse_charmap(pokered) + data = { + "source": "constants/charmap.asm, gfx/font/font.png, gfx/font/font_extra.png", + "image": "assets/generated/fonts/font.png", + "imageExtra": "assets/generated/fonts/font_extra.png", + # font.png holds codes $80..$FF, font_extra.png holds $60..$7F + "mainBase": 0x80, + "extraBase": 0x60, + "glyphsPerRow": main[0] // 8, + "charmap": charmap, + } + util.write_lua(os.path.join(out_dir, "font.lua"), data, + header="Charmap sorted longest-first for greedy matching.") + return data diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py new file mode 100644 index 00000000..8b307fd9 --- /dev/null +++ b/tools/extract/gfx.py @@ -0,0 +1,795 @@ +"""Graphics conversion for pret/pokered PNGs. + +The repo stores Game Boy graphics as 2-bit (or 1-bit) grayscale PNGs where +the *lightest* gray level corresponds to GB color 0 (this matches rgbgfx's +convention). We convert them to RGBA PNGs using the classic DMG green-less +grayscale palette so LÖVE can load them directly. + +Transparency modes: + * oam , every GB color 0 pixel becomes alpha 0 (hardware OAM rule; + overworld people, emotes, battle anim sprites). + * matte, only color-0 pixels connected to the image edge become alpha 0. + Interior whites (hat highlights, Articuno's body, etc.) stay + opaque. Use this for BG-style plates that need a clear + background without punching holes in white artwork. This is + the RGBA equivalent of remapping shades into [0,127] and + keeping 255 as a color key. +""" + +import os +import re +from collections import deque + +from PIL import Image + +from . import util +from .util import parse_number, read_asm, split_args + +# GB color 0..3 -> RGBA, lightest to darkest. +GB_SHADES = [ + (255, 255, 255, 255), + (170, 170, 170, 255), + (85, 85, 85, 255), + (0, 0, 0, 255), +] + + +def _gray_to_index(v): + """Map a grayscale byte (0/85/170/255) to a GB color index (0 = lightest). + + 1-bit sources only use 0/255, which map to 3/0, the same rounding + formula covers both depths. + """ + return 3 - round(v / 85) + + +def _matte_color0(out): + """Flood-fill edge-connected opaque white (GB color 0) to alpha 0.""" + w, h = out.size + px = out.load() + q = deque() + seen = set() + + def is_opaque_white(x, y): + r, g, b, a = px[x, y] + return a == 255 and r == 255 and g == 255 and b == 255 + + for x in range(w): + for y in (0, h - 1): + if is_opaque_white(x, y): + seen.add((x, y)) + q.append((x, y)) + for y in range(h): + for x in (0, w - 1): + if (x, y) not in seen and is_opaque_white(x, y): + seen.add((x, y)) + q.append((x, y)) + + while q: + x, y = q.popleft() + px[x, y] = (255, 255, 255, 0) + for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen \ + and is_opaque_white(nx, ny): + seen.add((nx, ny)) + q.append((nx, ny)) + return out + + +def _convert_image(im, transparent_color0=False, transparent_matte=False): + """Convert a grayscale PIL image to an RGBA image with the GB palette.""" + im = im.convert("L") + out = Image.new("RGBA", im.size) + src_px = im.load() + dst_px = out.load() + # Matte needs opaque whites first, then edge flood-fill. OAM clears + # every color-0 pixel up front. + clear_color0 = transparent_color0 and not transparent_matte + for y in range(im.size[1]): + for x in range(im.size[0]): + idx = _gray_to_index(src_px[x, y]) + if idx == 0 and clear_color0: + dst_px[x, y] = (255, 255, 255, 0) + else: + dst_px[x, y] = GB_SHADES[idx] + if transparent_matte: + _matte_color0(out) + return out + + +def _save_png(out, dst): + os.makedirs(os.path.dirname(dst), exist_ok=True) + out.save(dst, optimize=True) + + +def convert_png(src, dst, transparent_color0=False, transparent_matte=False): + """Convert a pokered grayscale PNG to an RGBA PNG with the GB palette.""" + im = Image.open(src) + _save_png(_convert_image(im, transparent_color0, transparent_matte), dst) + return im.size + + +# Title screen graphics (drawn by engine/movie/title.asm): +# PokemonLogoGraphics gfx/title/pokemon_logo.png (128x56, 2bpp) +# Version_GFX (Red) gfx/title/red_version.png (80x8, 1bpp) +# PlayerCharacterTitleGraphics gfx/title/player.png (40x56, 2bpp) +# NintendoCopyrightLogoGraphics gfx/splash/copyright.png (152x8, 2bpp) +# The Red front pic on the title screen is NOT a trainer pic (the player is +# not in data/trainers/), so it is converted here from gfx/title/player.png. +# player is OAM in the ROM, but color 0 is also used for hat/vest/shoe +# highlights that read as white against the title's white BG, matte keeps +# those while clearing the surrounding plate. +TITLE_GRAPHICS = [ + ("logo", "gfx/title/pokemon_logo.png", False), + ("version", "gfx/title/red_version.png", False), + ("player", "gfx/title/player.png", True), + ("copyright", "gfx/splash/copyright.png", False), + # GameFreakLogoGraphics: the "GAME FREAK inc." row of the copyright + # block (tiles $73-$7B, drawn by LoadCopyrightTiles and reused by the + # end credits' CRED_COPYRIGHT screen) + ("gamefreakInc", "gfx/title/gamefreak_inc.png", False), +] + + +def extract_title(pokered, assets_dir): + """Convert the title-screen graphics to assets/generated/title/. + + Returns a manifest dict (key -> {path, width, height, source}); there is + no separate gfx manifest file, so the caller embeds this in field.lua + under the `title` key. + """ + out = {} + for key, src_rel, matte in TITLE_GRAPHICS: + base = os.path.basename(src_rel) + size = convert_png(os.path.join(pokered, src_rel), + os.path.join(assets_dir, "title", base), + transparent_matte=matte) + out[key] = { + "path": f"assets/generated/title/{base}", + "width": size[0], + "height": size[1], + "source": src_rel, + } + return out + + +# --------------------------------------------------------------------------- +# Boot splash + attract-movie graphics +# (engine/movie/splash.asm + engine/movie/intro.asm) +# --------------------------------------------------------------------------- + +def _read_text(pokered, rel): + with open(os.path.join(pokered, rel), encoding="utf-8") as f: + return f.read() + + +def _parse_oam_block(pokered, label): + """The dbsprite entries of a labelled OAM block in splash.asm. + + dbsprite x, y, xpix, ypix, tile, attrs (macros/gfx.asm) -> the sprite's + top-left lands at screen pixel (x*8 + xpix, y*8 + ypix). Returns + (x, y, tile, attrs-string) tuples. + """ + out = [] + in_block = False + for lineno, line in read_asm(os.path.join(pokered, "engine/movie/splash.asm")): + s = line.strip() + if s == f"{label}:": + in_block = True + continue + if in_block: + m = re.match(r"dbsprite\s+(\d+),\s*(\d+),\s*(\d+),\s*(\d+)," + r"\s*(\$\w+),\s*(.*)$", s) + if not m: + break + if m.group(3) != "0" or m.group(4) != "0": + util.die(f"splash.asm:{lineno}: unexpected dbsprite pixel offset") + out.append((int(m.group(1)), int(m.group(2)), + parse_number(m.group(5)), m.group(6).strip())) + if not out: + util.die(f"splash.asm: OAM block {label} not found") + return out + + +def _rebuild_gengar_poses(pokered): + """The three Gengar intro poses as 56x56 grayscale images. + + gfx/intro/gengar.png (168x56) holds the three poses, but the ROM tile + sheet is built with `rgbgfx --columns` (column-major tile order) and + `tools/gfx --remove-duplicates --preserve=0x19,0x76` (Makefile:176-177), + and each pose is drawn by recomposing that deduplicated sheet through a + gfx/intro/gengar_N.tilemap: 49 bytes = 7x7 row-major tile indices + (tile_ids GengarIntroTiles{1,2,3}, 7, 7 in data/tilemaps.asm), written + to the screen at hlcoord 13,7 by IntroCopyTiles -> CopyTileIDs + (engine/movie/intro.asm:271-276, engine/battle/animations.asm:2284). + Tile 0 is blank (white) and tile 1 is the solid black tile reused for + the intro's letterbox bars (IntroPlaceBlackTiles, intro.asm:227-233). + """ + makefile = _read_text(pokered, "Makefile") + if "gfx/intro/gengar.2bpp: RGBGFXFLAGS += --columns" not in makefile \ + or "gfx/intro/gengar.2bpp: tools/gfx += --remove-duplicates " \ + "--preserve=0x19,0x76" not in makefile: + util.die("Makefile: gengar.2bpp build flags changed") + tilemaps_asm = _read_text(pokered, "data/tilemaps.asm") + for n in (1, 2, 3): + if not re.search(rf"tile_ids GengarIntroTiles{n},\s*7,\s*7", tilemaps_asm): + util.die(f"tilemaps.asm: GengarIntroTiles{n} is no longer 7x7") + intro_asm = _read_text(pokered, "engine/movie/intro.asm") + if "hlcoord 13, 7" not in intro_asm: + util.die("intro.asm: IntroCopyTiles destination changed") + + im = Image.open(os.path.join(pokered, "gfx/intro/gengar.png")).convert("L") + if im.size != (168, 56): + util.die(f"gengar.png: expected 168x56, got {im.size}") + + # rgbgfx --columns tile order, then tools/gfx remove_duplicates: a tile + # is dropped if an earlier kept tile is identical, unless its original + # index is in the --preserve list + tiles = [im.crop((tx * 8, ty * 8, tx * 8 + 8, ty * 8 + 8)) + for tx in range(21) for ty in range(7)] + kept, seen = [], [] + for idx, t in enumerate(tiles): + b = t.tobytes() + if b in seen and idx not in (0x19, 0x76): + continue + kept.append(t) + seen.append(b) + if len(kept) != 95 or set(kept[0].tobytes()) != {255} \ + or set(kept[1].tobytes()) != {0}: + util.die(f"gengar.png: deduplicated to {len(kept)} tiles " + "(expected 95 with blank tile 0 / black tile 1)") + + poses = [] + for n in (1, 2, 3): + with open(os.path.join(pokered, f"gfx/intro/gengar_{n}.tilemap"), + "rb") as f: + tilemap = f.read() + if len(tilemap) != 49 or max(tilemap) >= len(kept): + util.die(f"gengar_{n}.tilemap: not 49 in-range tile ids") + pose = Image.new("L", (56, 56), 255) + for i, tid in enumerate(tilemap): + pose.paste(kept[tid], ((i % 7) * 8, (i // 7) * 8)) + # each pose must reproduce its 56x56 slice of the source PNG; the + # only known exception is pose 1's tile (0,1), where the PNG stores + # the solid black bar tile but the tilemap places blank + crop = im.crop(((n - 1) * 56, 0, n * 56, 56)) + for ty in range(7): + for tx in range(7): + box = (tx * 8, ty * 8, tx * 8 + 8, ty * 8 + 8) + if pose.crop(box).tobytes() != crop.crop(box).tobytes() \ + and not (n == 1 and (tx, ty) == (0, 1)): + util.die(f"gengar pose {n}: tile ({tx},{ty}) does not " + "match the source PNG") + poses.append(pose) + return poses + + +def extract_intro(pokered, assets_dir): + """Splash + intro fight graphics -> assets/generated/intro/. + + Splash (PlayShootingStar, intro.asm:305-341 + splash.asm): + * falling_star.png: the small-stars OAM tile $A2 (splash.asm:148-150, + 237-239) that rains from the logo in 4 waves. + * big_star.png: the big shooting star is NOT falling_star -- it is + two tiles of the battle move animation sheet, MoveAnimationTiles1 + tiles 3 and 19 (gfx/battle/move_anim_1.png), left column plus an + X-flipped right column (splash.asm:6-13, 230-235). + * gamefreak_logo.png (16x24) drawn at screen (72,56) and the "GAME + FREAK" letter row at (40,80), both OAM (GameFreakLogoOAMData, + splash.asm:211-228). The letter row reuses tiles of + gamefreak_presents.png; gamefreak_text.png is that row pre-composed + (80x8). The "presents" tiles themselves are unused in the English + release (LoadPresentsGraphic dummied out, intro.asm:359-364). + All splash graphics are OAM sprites -> color 0 transparent. + + Fight (PlayIntroScene, intro.asm:23-141): + * gengar_{1,2,3}.png: 56x56 poses rebuilt from gengar.png through the + gengar_{1,2,3}.tilemap files (see _rebuild_gengar_poses). The port + moves each pose as one image, so edge-connected color 0 is matted + like the title-screen Red portrait while interior whites remain. + * red_nidorino_{1,2,3}.png: 48x48 OAM poses -> color 0 transparent. + """ + out_dir = os.path.join(assets_dir, "intro") + + def entry(base, size, source): + return {"path": f"assets/generated/intro/{base}", + "width": size[0], "height": size[1], "source": source} + + manifest = {} + for key, rel in (("fallingStar", "gfx/splash/falling_star.png"), + ("gamefreakLogo", "gfx/splash/gamefreak_logo.png"), + ("gamefreakPresents", "gfx/splash/gamefreak_presents.png")): + base = os.path.basename(rel) + size = convert_png(os.path.join(pokered, rel), + os.path.join(out_dir, base), transparent_color0=True) + manifest[key] = entry(base, size, rel) + if manifest["fallingStar"]["width"] != 8 \ + or (manifest["gamefreakLogo"]["width"], + manifest["gamefreakLogo"]["height"]) != (16, 24) \ + or manifest["gamefreakPresents"]["width"] != 104: + util.die("splash graphics: unexpected sizes") + + # falling_star.png holds two small stars: the upper one in GB color 1, + # the lower one in color 2. MoveDownSmallStars toggles OBP1 with + # %10100000 every step (splash.asm:199-203), blanking colors 2/3 so the + # lower star blinks; falling_star_blink.png is that toggled state + # (color >= 2 hidden) for pixel-exact blinking. + star_src = Image.open( + os.path.join(pokered, "gfx/splash/falling_star.png")).convert("L") + blink = Image.new("RGBA", star_src.size, (255, 255, 255, 0)) + n_hidden = 0 + for y in range(star_src.size[1]): + for x in range(star_src.size[0]): + idx = _gray_to_index(star_src.getpixel((x, y))) + if idx == 1: + blink.putpixel((x, y), GB_SHADES[1]) + elif idx >= 2: + n_hidden += 1 + if not n_hidden: + util.die("falling_star.png: no color-2 (blinking) star pixels found") + _save_png(blink, os.path.join(out_dir, "falling_star_blink.png")) + manifest["fallingStarBlink"] = entry( + "falling_star_blink.png", blink.size, + "gfx/splash/falling_star.png with OBP1 colors 2/3 blanked " + "(MoveDownSmallStars, engine/movie/splash.asm:199-203)") + + # the "GAME FREAK" letter row: OAM entries on grid row 12 place + # gamefreak_presents tiles $80.. plus the blank tile $93 (splash.asm) + oam = _parse_oam_block(pokered, "GameFreakLogoOAMData") + text_row = sorted((x, tile) for x, y, tile, _ in oam if y == 12) + logo_row = sorted((y, x, tile) for x, y, tile, _ in oam if y != 12) + if [t for _, _, t in logo_row] != [0x8D + i for i in range(6)] \ + or [(y, x) for y, x, _ in logo_row] != \ + [(y, x) for y in (9, 10, 11) for x in (10, 11)]: + util.die("splash.asm: GameFreakLogoOAMData logo arrangement changed") + presents = _convert_image( + Image.open(os.path.join(pokered, "gfx/splash/gamefreak_presents.png")), + transparent_color0=True) + text_img = Image.new("RGBA", (8 * len(text_row), 8), (255, 255, 255, 0)) + for i, (x, tile) in enumerate(text_row): + if x != text_row[0][0] + i: + util.die("splash.asm: GAME FREAK letter row not contiguous") + if tile != 0x93: # $93 = the blank tile after the logo tiles + if not 0x80 <= tile <= 0x8C: + util.die(f"splash.asm: letter tile ${tile:02x} out of range") + col = tile - 0x80 + text_img.paste(presents.crop((col * 8, 0, col * 8 + 8, 8)), + (i * 8, 0)) + _save_png(text_img, os.path.join(out_dir, "gamefreak_text.png")) + manifest["gamefreakText"] = entry( + "gamefreak_text.png", text_img.size, + "gfx/splash/gamefreak_presents.png via GameFreakLogoOAMData " + "(engine/movie/splash.asm:218-227)") + + # big shooting star: MoveAnimationTiles1 tiles 3 (top left) and 19 + # (bottom left), right column X-flipped (splash.asm:6-13, 230-235) + splash_asm = _read_text(pokered, "engine/movie/splash.asm") + if "MoveAnimationTiles1 tile 3" not in splash_asm \ + or "MoveAnimationTiles1 tile 19" not in splash_asm: + util.die("splash.asm: big star tile sources changed") + anim = _convert_image( + Image.open(os.path.join(pokered, "gfx/battle/move_anim_1.png")), + transparent_color0=True) + if anim.size[0] != 128: + util.die(f"move_anim_1.png: expected width 128, got {anim.size}") + star = Image.new("RGBA", (16, 16), (255, 255, 255, 0)) + for row, tile in ((0, 3), (1, 19)): + x, y = (tile % 16) * 8, (tile // 16) * 8 + quad = anim.crop((x, y, x + 8, y + 8)) + star.paste(quad, (0, row * 8)) + star.paste(quad.transpose(Image.FLIP_LEFT_RIGHT), (8, row * 8)) + _save_png(star, os.path.join(out_dir, "big_star.png")) + manifest["bigStar"] = entry( + "big_star.png", star.size, + "gfx/battle/move_anim_1.png tiles 3/19 via " + "GameFreakShootingStarOAMData (engine/movie/splash.asm:6-13,230-235)") + + manifest["gengar"] = {} + for n, pose in enumerate(_rebuild_gengar_poses(pokered), 1): + base = f"gengar_{n}.png" + _save_png( + _convert_image(pose, transparent_matte=True), + os.path.join(out_dir, base)) + manifest["gengar"][f"frame{n}"] = entry( + base, pose.size, + f"gfx/intro/gengar.png via gfx/intro/gengar_{n}.tilemap " + "(TILEMAP_GENGAR_INTRO_*, engine/movie/intro.asm)") + + manifest["nidorino"] = {} + for n in (1, 2, 3): + rel = f"gfx/intro/red_nidorino_{n}.png" + base = os.path.basename(rel) + size = convert_png(os.path.join(pokered, rel), + os.path.join(out_dir, base), transparent_color0=True) + if size != (48, 48): + util.die(f"{rel}: expected 48x48, got {size}") + manifest["nidorino"][f"frame{n}"] = entry(base, size, rel) + + manifest["source"] = ( + "gfx/splash/*.png, gfx/intro/*.png + gengar_{1,2,3}.tilemap, " + "gfx/battle/move_anim_1.png, engine/movie/splash.asm, " + "engine/movie/intro.asm (PlayShootingStar, PlayIntroScene)") + return manifest + + +# --------------------------------------------------------------------------- +# Slot machine wheel symbols +# --------------------------------------------------------------------------- + +def extract_slots(pokered, assets_dir): + """Slot machine graphics and the wheel-symbol crop table. + + LoadSlotMachineTiles (engine/slots/slot_machine.asm) copies + SlotMachineTiles2 (gfx/slots/red_slots_2.png, 32x48 = 24 tiles, via + engine/battle/animations.asm) into vChars0, so the spinning wheel + symbols are OAM sprites with tile ids $00-$17. SlotMachine_AnimWheel + draws one byte of the wheel list per 8-pixel row, bottom-up + (wBaseCoordY starts at $58 and shrinks by 8), as two side-by-side + sprites with tiles t and t+1. Each `dw SLOTS*` wheel entry + (constants/script_constants.asm) is therefore one 16x16 symbol: the + LOW byte is its bottom tile pair and the HIGH byte its top tile pair + (SLOTS7 EQU $0200 -> top tiles $02/$03, bottom tiles $00/$01). + + In the 32x48 source sheet tile n sits at ((n % 4) * 8, (n // 4) * 8), + so each symbol occupies one full 32x8 strip: the right 16x8 half is + the symbol's top row and the left half its bottom row. We reassemble + the six symbols into contiguous 16x16 crops in symbols.png (color 0 + transparent, since the wheels are OAM sprites) and also convert both + raw sheets. + """ + path = os.path.join(pokered, "constants/script_constants.asm") + order = [] + consts = {} + for lineno, line in read_asm(path): + m = re.match(r"DEF\s+SLOTS(\w+)\s+EQU\s+(\$\w+)", line.strip()) + if m and not m.group(1).startswith("_"): + order.append(m.group(1)) + consts[m.group(1)] = parse_number(m.group(2)) + if order != ["7", "BAR", "CHERRY", "FISH", "BIRD", "MOUSE"]: + util.die(f"script_constants.asm: unexpected SLOTS* symbols {order}") + + engine = "\n".join(l.strip() for _, l in read_asm( + os.path.join(pokered, "engine/slots/slot_machine.asm"))) + if not re.search(r"ld hl, SlotMachineTiles2\s+ld de, vChars0", engine) \ + or "ld a, $58" not in engine: + util.die("slot_machine.asm: wheel tile loading/drawing code changed") + + src = Image.open(os.path.join(pokered, "gfx/slots/red_slots_2.png")) + if src.size != (32, 48): + util.die(f"red_slots_2.png: expected 32x48, got {src.size}") + rgba = _convert_image(src, transparent_color0=True) + + def tile_pair(n): + """16x8 strip for OAM tiles n, n+1.""" + x, y = (n % 4) * 8, (n // 4) * 8 + return rgba.crop((x, y, x + 16, y + 8)) + + sheet = Image.new("RGBA", (16 * len(order), 16), (255, 255, 255, 0)) + symbols = {} + for i, name in enumerate(order): + value = consts[name] + hi, lo = value >> 8, value & 0xFF + if hi != lo + 2 or lo % 4 != 0 or hi + 1 >= 24: + util.die(f"SLOTS{name} = ${value:04x}: not a 2x2 tile pair in the sheet") + sheet.paste(tile_pair(hi), (i * 16, 0)) # high byte = top row + sheet.paste(tile_pair(lo), (i * 16, 8)) # low byte = bottom row + symbols[name] = { + "sheet": "assets/generated/slots/symbols.png", + "x": i * 16, "y": 0, "w": 16, "h": 16, + "tiles": value, # dw SLOTS* value: high/low = top/bottom tile pair + } + _save_png(sheet, os.path.join(assets_dir, "slots", "symbols.png")) + + sheets = {} + for key, rel in (("background", "gfx/slots/red_slots_1.png"), + ("wheel", "gfx/slots/red_slots_2.png")): + base = os.path.basename(rel) + size = convert_png(os.path.join(pokered, rel), + os.path.join(assets_dir, "slots", base)) + sheets[key] = {"path": f"assets/generated/slots/{base}", + "width": size[0], "height": size[1], "source": rel} + + # Static machine background tilemap (SlotMachineMap, INCBIN'd from + # gfx/slots/slots.tilemap by slot_machine.asm and copied to the BG map by + # LoadSlotMachineTiles). It is 20xN tile ids that index the vChars2 + # background tiles; LoadSlotMachineTiles fills vChars2 with SlotMachineTiles1 + # (red_slots_1.png) first, so every id < $25 is one tile of that sheet. We + # store the grid plus the sheet's tile-atlas stride so the port can blit the + # frame straight from red_slots_1.png. + if not re.search(r'SlotMachineMap:\s*INCBIN "gfx/slots/slots\.tilemap"', + engine): + util.die("slot_machine.asm: SlotMachineMap tilemap include changed") + with open(os.path.join(pokered, "gfx/slots/slots.tilemap"), "rb") as fh: + raw = list(fh.read()) + cols = 20 # SCREEN_WIDTH + if not raw or len(raw) % cols != 0: + util.die(f"slots.tilemap: {len(raw)} bytes is not a whole 20-col grid") + rows = len(raw) // cols + bg_tile_cols = sheets["background"]["width"] // 8 + bg_tile_count = bg_tile_cols * (sheets["background"]["height"] // 8) + if max(raw) >= 0x25 or max(raw) >= bg_tile_count: + util.die("slots.tilemap: tile id outside red_slots_1 / vChars2 range") + tilemap = { + "cols": cols, "rows": rows, + "sheet": sheets["background"]["path"], + "tileCols": bg_tile_cols, # red_slots_1.png is a tileCols-wide atlas + "tiles": [raw[r * cols:(r + 1) * cols] for r in range(rows)], + "source": "gfx/slots/slots.tilemap (SlotMachineMap)", + } + + return { + "sheet": "assets/generated/slots/symbols.png", + "width": 16 * len(order), "height": 16, + "order": order, # constant definition order + "symbols": symbols, # keys match field.lua's slotWheels names + "sheets": sheets, + "tilemap": tilemap, # 20x12 static machine frame (red_slots_1) + "source": "constants/script_constants.asm (SLOTS*), " + "engine/slots/slot_machine.asm (LoadSlotMachineTiles, " + "SlotMachine_AnimWheel), gfx/slots/red_slots_{1,2}.png, " + "gfx/slots/slots.tilemap", + } + + +# --------------------------------------------------------------------------- +# Oak speech shrink frames +# --------------------------------------------------------------------------- + +def extract_oak_speech(pokered, assets_dir): + """The player-pic shrink frames from the end of the Oak speech. + + OakSpeech (engine/movie/oak_speech/oak_speech.asm .next) collapses + RedPicFront through ShrinkPic1 and ShrinkPic2 (gfx/player.asm -> + gfx/player/shrink{1,2}.png, 7x7-tile pics like the trainer pics) + into the overworld walking sprite. Converted like gfx/player/red.png + (the trainer-card front pic): whites matted transparent. + """ + out = {} + for name in ("shrink1", "shrink2"): + size = convert_png(os.path.join(pokered, f"gfx/player/{name}.png"), + os.path.join(assets_dir, "intro", f"{name}.png"), + transparent_matte=True) + if size != (56, 56): + util.die(f"gfx/player/{name}.png: expected 56x56, got {size}") + out[name] = f"assets/generated/intro/{name}.png" + out["source"] = ("gfx/player/shrink{1,2}.png " + "(engine/movie/oak_speech/oak_speech.asm ShrinkPic1/2)") + return out + + +# --------------------------------------------------------------------------- +# Emotion bubbles +# --------------------------------------------------------------------------- + +def extract_emotes(pokered, assets_dir): + """The overworld emotion bubbles (engine/overworld/emotion_bubbles.asm). + + EmotionBubble copies 4 tiles (one 16x16 OAM block) from the entry of + EmotionBubblesPointerTable selected by wWhichEmotionBubble; the indexes + are the *_BUBBLE constants at the top of constants/script_constants.asm + (EXCLAMATION_BUBBLE=0 -> ShockEmote, QUESTION_BUBBLE=1 -> QuestionEmote, + SMILE_BUBBLE=2 -> HappyEmote). The three 16x16 PNGs are packed into + one sheet, color 0 transparent (they are OAM sprites). + """ + consts = util.parse_const_block( + os.path.join(pokered, "constants/script_constants.asm"), stop_at="SLOTS7") + ptr = [] + incbins = {} + path = os.path.join(pokered, "engine/overworld/emotion_bubbles.asm") + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"dw\s+(\w+Emote)$", s) + if m: + ptr.append(m.group(1)) + continue + m = re.match(r'(\w+Emote):\s*INCBIN\s+"(gfx/emotes/\w+)\.2bpp"', s) + if m: + incbins[m.group(1)] = m.group(2) + ".png" + if consts != ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE"] \ + or len(ptr) != 3 or set(ptr) != set(incbins): + util.die("emotion bubble constants/pointer table changed") + + sheet = Image.new("RGBA", (16 * len(ptr), 16), (255, 255, 255, 0)) + bubbles = [] + for i, label in enumerate(ptr): + rel = incbins[label] + im = Image.open(os.path.join(pokered, rel)) + if im.size != (16, 16): + util.die(f"{rel}: expected 16x16, got {im.size}") + sheet.paste(_convert_image(im, transparent_color0=True), (i * 16, 0)) + bubbles.append({"name": consts[i], "x": i * 16, "y": 0, "w": 16, "h": 16, + "source": rel}) + _save_png(sheet, os.path.join(assets_dir, "emotes.png")) + return { + "path": "assets/generated/emotes.png", + "width": 16 * len(ptr), "height": 16, + "bubbles": bubbles, # index = *_BUBBLE constant value + "source": "engine/overworld/emotion_bubbles.asm, gfx/emotes/*.png, " + "constants/script_constants.asm (*_BUBBLE)", + } + + +# --------------------------------------------------------------------------- +# Overworld effect art (gfx/overworld/*.png): the ledge-hop shadow, the +# fishing rod + player-fishing overlays, the Pokémon Center heal +# machine, and the battle-transition tile. The pokedex frame tiles +# ride along (gfx/pokedex/pokedex.png). +# --------------------------------------------------------------------------- + +def extract_overworld_fx(pokered, assets_dir): + out = {} + fx = [ + ("shadow", "gfx/overworld/shadow.png", True), + ("fishingRod", "gfx/overworld/fishing_rod.png", True), + ("redFishSide", "gfx/overworld/red_fish_side.png", True), + ("redFishFront", "gfx/overworld/red_fish_front.png", True), + ("redFishBack", "gfx/overworld/red_fish_back.png", True), + # OAM tiles: color 0 is transparent (the ball tile's corners) + ("healMachine", "gfx/overworld/heal_machine.png", True), + # one 8x8 tile drawn as a 2x2 block (LoadSmokeTileFourTimes): + # the Cut / boulder-push dust puff + ("smoke", "gfx/overworld/smoke.png", True), + ("battleTransition", "gfx/overworld/battle_transition.png", False), + ("pokedexFrame", "gfx/pokedex/pokedex.png", False), + ] + os.makedirs(os.path.join(assets_dir, "fx"), exist_ok=True) + for key, rel, transparent in fx: + base = os.path.splitext(os.path.basename(rel))[0] + dst = os.path.join(assets_dir, "fx", base + ".png") + size = convert_png(os.path.join(pokered, rel), dst, + transparent_color0=transparent) + out[key] = { + "path": f"assets/generated/fx/{base}.png", + "width": size[0], "height": size[1], "source": rel, + } + return out + + +# --------------------------------------------------------------------------- +# Credits "THE END" graphic +# --------------------------------------------------------------------------- + +def extract_the_end(pokered, assets_dir): + """gfx/credits/the_end.png, drawn by Credits .showTheEnd. + + The Makefile builds the_end.2bpp with `tools/gfx --interleave`, which + stores each pair of vertically stacked 8x8 tiles consecutively; the + 40x16 PNG is therefore the natural image of five 8x16 letters + T, H, E, N, D left to right, and 2bpp tile $60+2c / $60+2c+1 is the + top/bottom half of PNG column c. TheEndTextString + (engine/movie/credits.asm) lays those columns out as "T H E E N D". + `pattern` lists, per screen column, which 8x16 letter column of the + PNG to draw (-1 = blank). + """ + with open(os.path.join(pokered, "Makefile"), encoding="utf-8") as f: + if not any("the_end.2bpp" in l and "--interleave" in l for l in f): + util.die("Makefile: the_end.2bpp is no longer interleaved") + + rows = [] + current = None + for lineno, line in read_asm(os.path.join(pokered, "engine/movie/credits.asm")): + s = line.strip() + if s == "TheEndTextString:": + rows = [] + current = rows + continue + if current is None: + continue + m = re.match(r"db\s+(.+)$", s) + if not m: + if s: + current = None + continue + row = [] + for tok in split_args(m.group(1)): + if tok.startswith('"'): + for ch in tok[1:-1]: + if ch == " ": + row.append(-1) + elif ch != "@": + util.die(f"credits.asm:{lineno}: unexpected char {ch!r} in THE END") + else: + row.append(parse_number(tok)) + rows.append(row) + if len(rows) != 2 or len(rows[0]) != len(rows[1]): + util.die("credits.asm: TheEndTextString shape changed") + pattern = [] + for top, bottom in zip(rows[0], rows[1]): + if top == -1: + if bottom != -1: + util.die("credits.asm: THE END rows misaligned") + pattern.append(-1) + else: + if bottom != top + 1 or top % 2 != 0 or not 0x60 <= top <= 0x68: + util.die(f"credits.asm: THE END tiles {top:#x}/{bottom:#x} not a column pair") + pattern.append((top - 0x60) // 2) + letters = "THEND" + display = "".join(letters[c] if c >= 0 else " " for c in pattern) + if display != "T H E E N D": + util.die(f"credits.asm: THE END layout changed: {display!r}") + + size = convert_png(os.path.join(pokered, "gfx/credits/the_end.png"), + os.path.join(assets_dir, "credits", "the_end.png")) + if size != (40, 16): + util.die(f"the_end.png: expected 40x16, got {size}") + return { + "path": "assets/generated/credits/the_end.png", + "width": size[0], "height": size[1], + "letters": letters, # PNG columns, each 8x16 (x = index * 8) + "letterWidth": 8, "letterHeight": 16, + "pattern": pattern, # screen columns -> PNG letter column (-1 = blank) + "display": display, + "source": "gfx/credits/the_end.png (Makefile --interleave), " + "engine/movie/credits.asm (TheEndTextString, .showTheEnd)", + } + + +# --------------------------------------------------------------------------- +# In-battle HUD tiles +# --------------------------------------------------------------------------- + +# During battles the GB overlays the $62-$7F font area with the HP bar / +# status sheet (home/load_font.asm LoadHpBarAndStatusTilePatterns -> tile +# $62) and the HUD line tiles (engine/battle/core.asm LoadHudTilePatterns: +# battle_hud_1 -> tile $6D, battle_hud_2+3 -> tile $73). Color 0 is +# exported transparent so the underlines can overlap the mon pics. +BATTLE_HUD_GRAPHICS = [ + ("fontBattleExtra", "gfx/font/font_battle_extra.png", 0x62), + ("hud1", "gfx/battle/battle_hud_1.png", 0x6D), + ("hud2", "gfx/battle/battle_hud_2.png", 0x73), + ("hud3", "gfx/battle/battle_hud_3.png", 0x76), +] + + +def extract_battle_hud(pokered, assets_dir): + """Convert the battle HUD tile sheets to assets/generated/battle/.""" + out = {} + for key, src_rel, base in BATTLE_HUD_GRAPHICS: + name = os.path.basename(src_rel) + size = convert_png(os.path.join(pokered, src_rel), + os.path.join(assets_dir, "battle", name), + transparent_color0=True) + out[key] = { + "path": f"assets/generated/battle/{name}", + "width": size[0], + "height": size[1], + "tileBase": base, + "source": src_rel, + } + return out + + +# --------------------------------------------------------------------------- +# Town map background (engine/items/town_map.asm) +# --------------------------------------------------------------------------- + +def extract_town_map_bg(pokered, assets_dir): + """gfx/town_map/town_map.{rle,png}: the 20x18 Kanto map background. + + The RLE stream is one byte per run -- high nibble = tile index into + town_map.png, low nibble = run length -- terminated by $00 + (LoadTownMap's decompression loop). + """ + with open(os.path.join(pokered, "gfx/town_map/town_map.rle"), "rb") as f: + data = f.read() + tiles = [] + for b in data: + if b == 0: + break + tiles.extend([b >> 4] * (b & 0x0F)) + if len(tiles) != 20 * 18: + util.die(f"town_map.rle decoded to {len(tiles)} tiles (want 360)") + size = convert_png(os.path.join(pokered, "gfx/town_map/town_map.png"), + os.path.join(assets_dir, "townmap", "tiles.png")) + cursor = convert_png(os.path.join(pokered, "gfx/town_map/town_map_cursor.png"), + os.path.join(assets_dir, "townmap", "cursor.png"), + transparent_color0=True) + return { + "tiles": {"path": "assets/generated/townmap/tiles.png", + "width": size[0], "height": size[1]}, + "cursor": {"path": "assets/generated/townmap/cursor.png", + "width": cursor[0], "height": cursor[1]}, + "map": tiles, + "source": "gfx/town_map/town_map.rle + town_map.png " + "(engine/items/town_map.asm LoadTownMap)", + } diff --git a/tools/extract/icons.py b/tools/extract/icons.py new file mode 100644 index 00000000..deee49d8 --- /dev/null +++ b/tools/extract/icons.py @@ -0,0 +1,86 @@ +"""Extract the party-menu mon icons. + +Sources: + data/pokemon/menu_icons.asm MonPartyData: one ICON_* nybble per + species in Pokédex order + gfx/icons/*.png the bug/plant/quadruped/snake icons as + 8x32 columns of two 8x16 left halves + (animation frames 1+2); the other icons + reuse overworld sprites + (engine/menus/party_menu.asm) + +Output: data/generated/icons.lua (byDex list + icon -> asset paths) + assets/generated/icons/{bug,plant,quadruped,snake}.png + (16x32: two 16x16 frames stacked) +""" + +import os +import re + +from PIL import Image + +from . import gfx, util + +# ICON_* -> already-extracted overworld sprite sheets (16x16 frame 0) +SPRITE_ICONS = { + "MON": "assets/generated/sprites/monster.png", + "BALL": "assets/generated/sprites/poke_ball.png", + "HELIX": "assets/generated/sprites/fossil.png", + "FAIRY": "assets/generated/sprites/fairy.png", + "BIRD": "assets/generated/sprites/bird.png", + "WATER": "assets/generated/sprites/seel.png", +} + +SHEET_ICONS = ("BUG", "GRASS", "SNAKE", "QUADRUPED") +SHEET_FILES = { "BUG": "bug", "GRASS": "plant", "SNAKE": "snake", + "QUADRUPED": "quadruped" } + + +def _reassemble_icon(src, dst): + """8x32 column = two 8x16 LEFT halves (frames 1+2); each frame is + the half plus its X-mirror (AnimatePartyMon swaps tile frames; the + icons are symmetric). Output: 16x32, frames stacked.""" + im = Image.open(src).convert("L") + if im.size != (8, 32): + util.die(f"{src}: expected 8x32 icon column, got {im.size}") + out = Image.new("L", (16, 32), 255) + for f in range(2): + half = im.crop((0, f * 16, 8, (f + 1) * 16)) + out.paste(half, (0, f * 16)) + out.paste(half.transpose(Image.FLIP_LEFT_RIGHT), (8, f * 16)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + gfx._save_png(gfx._convert_image(out, transparent_color0=True), dst) + + +def extract(pokered, out_dir, assets_dir): + by_dex = [] + started = False + for lineno, line in util.read_asm( + os.path.join(pokered, "data/pokemon/menu_icons.asm")): + s = line.strip() + if s.startswith("MonPartyData:"): + started = True + continue + m = re.match(r"nybble\s+ICON_(\w+)", s) + if started and m: + by_dex.append(m.group(1)) + if len(by_dex) != 151: + util.die(f"menu_icons.asm parsed {len(by_dex)} icons (want 151)") + + icons = dict(SPRITE_ICONS) + for name in SHEET_ICONS: + fn = SHEET_FILES[name] + _reassemble_icon(os.path.join(pokered, f"gfx/icons/{fn}.png"), + os.path.join(assets_dir, f"icons/{fn}.png")) + icons[name] = f"assets/generated/icons/{fn}.png" + + util.write_lua(os.path.join(out_dir, "icons.lua"), + {"source": "data/pokemon/menu_icons.asm + gfx/icons/ " + "(engine/menus/party_menu.asm icon sprites)", + "byDex": by_dex, + "icons": icons}, + header="Party menu icons: ICON name per Pokédex number and\n" + "the image each icon draws from (sprite sheets use\n" + "frame 0; the 16x32 icon sheets stack two real\n" + "animation frames).") + return by_dex diff --git a/tools/extract/items.py b/tools/extract/items.py new file mode 100644 index 00000000..c2b7eb57 --- /dev/null +++ b/tools/extract/items.py @@ -0,0 +1,108 @@ +"""Extract item data, including TM/HM machine items. + +Sources: + constants/item_constants.asm -> item ids (const list, 1-based) and + add_tm/add_hm machine definitions + data/items/names.asm -> names (li "..." in id order) + data/items/prices.asm -> bcd3 prices in id order + data/items/tm_prices.asm -> TM prices in thousands (nybbles) + data/items/key_items.asm -> key item bitfield (dbit_env) + +Output: data/generated/items.lua +""" + +import os +import re + +from . import util +from .util import parse_number, read_asm, warn + + +def parse_machines(pokered): + """add_hm/add_tm rows: item ids HM_CUT.., TM_MEGA_PUNCH.. -> move.""" + hms, tms = [], [] + for lineno, line in read_asm(os.path.join(pokered, "constants/item_constants.asm")): + s = line.strip() + m = re.match(r"add_hm\s+(\w+)", s) + if m: + hms.append(m.group(1)) + m = re.match(r"add_tm\s+(\w+)", s) + if m: + tms.append(m.group(1)) + return hms, tms + + +def parse_tm_prices(pokered): + prices = [] + for lineno, line in read_asm(os.path.join(pokered, "data/items/tm_prices.asm")): + m = re.match(r"nybble\s+(\d+)", line.strip()) + if m: + prices.append(int(m.group(1)) * 1000) + return prices + + +def extract(pokered, out_dir): + consts = util.parse_const_block(os.path.join(pokered, "constants/item_constants.asm")) + consts = [c for c in consts[1:] if c] # drop NO_ITEM slot 0 + + names = [] + for lineno, line in read_asm(os.path.join(pokered, "data/items/names.asm")): + m = re.match(r'li\s+"([^"]*)"', line.strip()) + if m: + names.append(m.group(1)) + + prices = [] + for lineno, line in read_asm(os.path.join(pokered, "data/items/prices.asm")): + m = re.match(r"bcd3\s+([\d]+)", line.strip()) + if m: + prices.append(int(m.group(1))) + + # KeyItemFlags bit array (toss/deposit eligibility uses THIS, not + # price==0: e.g. MOON_STONE has price 0 but is tossable) + key_flags = [] + for lineno, line in read_asm(os.path.join(pokered, "data/items/key_items.asm")): + m = re.match(r"dbit\s+(TRUE|FALSE)", line.strip()) + if m: + key_flags.append(m.group(1) == "TRUE") + + out = {} + for i, const in enumerate(consts): + if i >= len(names): + break # named items only; machines are added below + out[const] = { + "id": const, + "index": i + 1, + "name": names[i].replace("#", "POKé"), + "price": prices[i] if i < len(prices) else 0, + "source": f"data/items/names.asm (entry {i + 1})", + } + if i < len(key_flags) and key_flags[i]: + out[const]["keyItem"] = True + if "POKE_BALL" not in out or out["POKE_BALL"]["price"] != 200: + util.die("item extraction sanity check failed (POKE_BALL price != 200)") + + hms, tms = parse_machines(pokered) + tm_prices = parse_tm_prices(pokered) + for n, move in enumerate(hms, start=1): + out["HM_" + move] = { + "id": "HM_" + move, + "name": "HM%02d" % n, + "price": 0, + "machine": {"kind": "HM", "number": n, "move": move}, + "source": "constants/item_constants.asm (add_hm)", + } + for n, move in enumerate(tms, start=1): + out["TM_" + move] = { + "id": "TM_" + move, + "name": "TM%02d" % n, + "price": tm_prices[n - 1] if n - 1 < len(tm_prices) else 0, + "machine": {"kind": "TM", "number": n, "move": move}, + "source": "constants/item_constants.asm (add_tm)", + } + if len(tms) != 50 or len(hms) != 5: + warn(f"expected 50 TMs / 5 HMs, got {len(tms)}/{len(hms)}") + + util.write_lua(os.path.join(out_dir, "items.lua"), out, + header="Sources: constants/item_constants.asm, data/items/names.asm,\n" + "prices.asm, tm_prices.asm") + return out diff --git a/tools/extract/maps.py b/tools/extract/maps.py new file mode 100644 index 00000000..1fc5cf22 --- /dev/null +++ b/tools/extract/maps.py @@ -0,0 +1,237 @@ +"""Extract map data: headers, block layouts, objects, warps, signs. + +Sources: + data/maps/headers/.asm -> map_header (tileset), connection directives + data/maps/objects/.asm -> border block, warp/bg/object events + maps/.blk -> width*height block indices + data/maps/names.asm -> display names (town map names, where mapped) + constants/map_constants.asm -> dimensions (parsed by constants.py) + +Output: data/generated/maps.lua + +Coordinates are in 16x16 "walk grid" cells, matching the macros' arguments. +""" + +import os +import re + +from . import util +from .util import parse_number, read_asm, split_args, warn + + +def parse_header(path): + hdr = {"connections": {}} + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"map_header\s+(\w+),\s*(\w+),\s*(\w+)", s) + if m: + hdr["label"] = m.group(1) + hdr["const"] = m.group(2) + hdr["tileset"] = m.group(3) + hdr["line"] = lineno + continue + m = re.match(r"connection\s+(\w+),\s*(\w+),\s*(\w+),\s*(-?[\w$%]+)", s) + if m: + hdr["connections"][m.group(1)] = { + "map": m.group(3), + "offset": parse_number(m.group(4)), + } + return hdr + + +def parse_objects(path): + """Parse a data/maps/objects/*.asm file.""" + out = {"warps": [], "signs": [], "objects": [], "borderBlock": 0, + "objectNames": []} + obj_index = 0 + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"const_export\s+(\w+)$", s) + if m: + out["objectNames"].append(m.group(1)) + continue + m = re.match(r"db\s+(\$?[0-9a-fA-F]+)$", s) + if m: + out["borderBlock"] = parse_number(m.group(1)) + continue + m = re.match(r"warp_event\s+(.*)$", s) + if m: + a = split_args(m.group(1)) + out["warps"].append({ + "x": parse_number(a[0]), + "y": parse_number(a[1]), + "destMap": a[2], + "destWarp": parse_number(a[3]), + }) + continue + m = re.match(r"bg_event\s+(.*)$", s) + if m: + a = split_args(m.group(1)) + out["signs"].append({ + "x": parse_number(a[0]), + "y": parse_number(a[1]), + "text": a[2], + }) + continue + m = re.match(r"object_event\s+(.*)$", s) + if m: + a = split_args(m.group(1)) + obj_index += 1 + obj = { + "index": obj_index, + "x": parse_number(a[0]), + "y": parse_number(a[1]), + "sprite": a[2], + "movement": a[3], # STAY / WALK + "range": a[4], # facing (STAY) or roam range (WALK) + "text": a[5], + } + # Extra args: trainers carry (OPP_class, party), static wild + # Pokémon carry (species, level), items carry (item). + if len(a) == 8: + if a[6].startswith("OPP_"): + obj["trainerClass"] = a[6] + obj["trainerParty"] = parse_number(a[7]) if re.match(r"^[\d$%]", a[7]) else a[7] + else: + obj["pokemon"] = a[6] + obj["level"] = parse_number(a[7]) + elif len(a) == 7: + obj["item"] = a[6] + out["objects"].append(obj) + continue + return out + + +def read_blk(path, width, height, border_block): + with open(path, "rb") as f: + raw = f.read() + if len(raw) < width * height: + # e.g. UndergroundPathNorthSouth.blk is 92 bytes for a 4x24 map; the + # original ROM reads past the file into whatever data follows it. + warn(f"{path}: expected {width * height} blocks, got {len(raw)}; padding with border block") + raw = raw + bytes([border_block]) * (width * height - len(raw)) + elif len(raw) > width * height: + util.die(f"{path}: expected {width * height} blocks, got {len(raw)}") + return list(raw) + + +def parse_toggleable_objects(pokered): + """data/maps/toggleable_objects.asm: initial ON/OFF state per object. + + Objects marked OFF exist in the object list but start hidden (e.g. Oak + in his lab, cuttable trees' post-cut states...). + """ + path = os.path.join(pokered, "data/maps/toggleable_objects.asm") + states = {} + current = None + for lineno, line in read_asm(path): + s = line.strip() + m = re.match(r"toggleable_objects_for\s+(\w+)$", s) + if m: + current = m.group(1) + states[current] = {} + continue + m = re.match(r"toggle_object_state\s+(\w+),\s*(ON|OFF)$", s) + if m and current: + states[current][m.group(1)] = m.group(2) + return states + + +def parse_blocks_files(pokered): + """maps.asm: `