diff --git a/assets/labels/blue.png b/assets/labels/blue.png new file mode 100644 index 00000000..710ab2d5 Binary files /dev/null and b/assets/labels/blue.png differ diff --git a/assets/labels/crystal.png b/assets/labels/crystal.png new file mode 100644 index 00000000..85b2fe93 Binary files /dev/null and b/assets/labels/crystal.png differ diff --git a/assets/labels/gold.png b/assets/labels/gold.png new file mode 100644 index 00000000..69c4fa02 Binary files /dev/null and b/assets/labels/gold.png differ diff --git a/assets/labels/red.png b/assets/labels/red.png new file mode 100644 index 00000000..c8d42baa Binary files /dev/null and b/assets/labels/red.png differ diff --git a/assets/labels/silver.png b/assets/labels/silver.png new file mode 100644 index 00000000..9f701d7c Binary files /dev/null and b/assets/labels/silver.png differ diff --git a/assets/labels/yellow.png b/assets/labels/yellow.png new file mode 100644 index 00000000..8cdb2a66 Binary files /dev/null and b/assets/labels/yellow.png differ diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index afc9fc4c..cfce77cb 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -246,9 +246,17 @@ return { -- the table sprites; re-entering the lab applies the same HideObject -- the gift script now does (OaksLab.asm OakGivesPokedex). onEnter = function(game, ow) - if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then - return + local flags = game.save.flags or {} + if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then + local rival = ow:npcByIndex(1) + if rival then + rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7 + or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6 + rival.cellY = 4 + rival.px, rival.py = rival.cellX * 16, rival.cellY * 16 + end end + if not flags.EVENT_GOT_POKEDEX then return end local Commands = require("src.script.Commands") local ctx = { save = game.save, game = game, overworld = ow } Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") @@ -286,6 +294,7 @@ return { -- fanfare for the taunt/challenge exchange, same as the Yellow port -- (oaks_lab_yellow.lua); it was silently dropped here (#596). local rows = { + { "face_player_dir", "up" }, { "stop_music" }, { "play_music", "Music_MeetRival" }, { "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1 @@ -309,28 +318,19 @@ return { local base = #rows local party = flags.EVENT_CHOSE_BULBASAUR and 3 or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1 + table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party }) -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout table.insert(rows, { "heal_party" }) table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) - -- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong - -- POKéMON!" gloat, then BOTH win and loss print the shared exit line - -- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to - -- toughen it up!\012! Gramps! Smell you later!") before Blue - -- marches out. A loss skips only the gloat (that taunt was already - -- shown in-battle via Rival1WinText), never the exit line (#231). The - -- jump_if_false convergence point is the exit line: base+6 indexes the - -- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and - -- LOSS jumps straight to SmellYouLater (both then walk-out + hide). table.insert(rows, { "jump_if_false", base + 6 }) - table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then -- walk out past the player. The fanfare was dropped here (#683) -- the -- parcel scene above already plays Music_MeetRival on both arrival and -- departure (lines 144-146), and this exit should match (#596). table.insert(rows, { "stop_music" }) - table.insert(rows, { "play_music", "Music_MeetRival" }) + table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } }) table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 91b1365d..e5cacf8a 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -226,9 +226,15 @@ return { }, onEnter = function(game, ow) - if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then - return + local flags = game.save.flags or {} + if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then + local rival = ow:npcByIndex(RIVAL) + if rival then + rival.cellX, rival.cellY = 7, 4 + rival.px, rival.py = 7 * 16, 4 * 16 + end end + if not flags.EVENT_GOT_POKEDEX then return end local Commands = require("src.script.Commands") local ctx = { save = game.save, game = game, overworld = ow } Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") @@ -296,13 +302,14 @@ return { table.insert(rows, { "wait", 20 }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "stop_music" }) - table.insert(rows, { "play_music", "Music_MeetRival" }) + table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } }) table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) -- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009) table.insert(rows, { "face_player_dir", "up" }) table.insert(rows, { "set_field", "pikachuInBall", false }) + table.insert(rows, { "spawn_pikachu_follower" }) table.insert(rows, { "play_cry", "PIKACHU" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" }) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 61eb95df..adbf2e6d 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = { 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 + { "face_player" }, + { "check_flag", "EVENT_GOT_TOWN_MAP" }, + { "jump_if_true", "got_map" }, + { "check_flag", "EVENT_GOT_POKEDEX" }, + { "jump_if_false", "too_early" }, + { "show_text", "_BluesHouseDaisyOfferMapText" }, -- _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 + { "give_item", "TOWN_MAP", 1, "_GotMapText" }, + { "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" }, + { "set_flag", "EVENT_GOT_TOWN_MAP" }, + { "jump", "end" }, + { "label", "got_map" }, + { "show_text", "_BluesHouseDaisyUseMapText" }, + { "jump", "end" }, + { "label", "too_early" }, + { "show_text", "_BluesHouseDaisyRivalAtLabText" }, }, }, } @@ -1058,6 +1061,7 @@ local championsRoomRivalScript = { -- OakCongratulatesPlayerScript: rival faces left, Oak faces down { "face_object", 1, "left" }, -- 17 { "face_object", 2, "down" }, -- 18 + { "load_player_starter_name" }, { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19 -- OakDisappointedWithRivalScript: Oak turns to the rival (right) { "face_object", 2, "right" }, -- 20 @@ -1067,13 +1071,8 @@ local championsRoomRivalScript = { { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23 { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25 - -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement. - -- The player walks out after Oak instead of the screen just fading on the - -- spot (#704). Route one tile right before walking north so the player - -- reaches the north-wall HALL_OF_FAME warp without sharing the rival's - -- (4,2) cell. The original simulated movement bypasses entity collision, - -- but this scene should not visibly walk through the defeated rival. - { "move_player", "right", 1 }, -- 26 + -- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement + { "move_player", "left", 1 }, { "move_player", "up", 3 }, -- 27 -- 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) @@ -1238,6 +1237,7 @@ local function pokemonTower2FRivalScript(playerX) { "jump_if_false", "end" }, -- 6 loss: stay { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 + { "play_music", "Music_MeetRival", { start = "rival" } }, { "walk_npc", 1, exitDirs }, -- 9 { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 { "jump", "end" }, -- 11 diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 96904d35..887a683e 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr -- ------------------------------------------------------------------- M.POKEMON_FAN_CLUB = { + onEnter = function(game, ow) + require("src.world.PikachuFollower").onFanClubEntered(game, ow) + end, talk = { TEXT_POKEMONFANCLUB_CHAIRMAN = { { "face_player" }, -- 1 diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index b4766406..b8c55163 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = { 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) + push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done) end) end, }, @@ -512,24 +512,29 @@ M.ROUTE_24 = { local flags = game.save.flags local function battleOrDone() if ow:trainerDefeated(npc) then - push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done) + push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText, + 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() + local t = text(game) + push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" + .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() + require("src.core.Sound").play(game.data, "Get_Item1") + if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, + game.data) then + push(game, t._Route24CooltrainerM1NoRoomText, done) + return + end 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) + game.stringBuffer = game.data.items.NUGGET.name + push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() + require("src.core.Sound").play(game.data, "Get_Item1") + push(game, t._Route24CooltrainerM1JoinTeamRocketText, + battleOrDone) + end) end) return end diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index b1a7ffc8..8aefafe3 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -508,12 +508,12 @@ M.PEWTER_CITY = { -- 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) +local function runAmbush(game, ow, rows, playerFacing, musicOpts) 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") + require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts) ow.runner:run(rows) return true end @@ -567,10 +567,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, { "face_object", objIndex, rivalFacing }, -- 3 { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 { "rival_battle", oppClass, baseParty }, -- 5 - { "jump_if_false", 11 }, -- 6 + { "jump_if_false", 12 }, -- 6 { "set_flag", beatFlag }, -- 7 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 + { "play_music", "Music_MeetRival", { start = "rival", + tempo = n == 2 and 100 or nil } }, { "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10 { "hide_object", "ROUTE_22", objName }, -- 11 } @@ -594,7 +596,8 @@ M.ROUTE_22 = { 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), playerFacing) + "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing, + { tempo = 100 }) end return false end, @@ -618,10 +621,11 @@ local function ceruleanRivalScene(px, py) { "face_object", 1, "down" }, -- 3 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 11 }, -- 6 + { "jump_if_false", 12 }, -- 6 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "play_music", "Music_MeetRival", { start = "rival" } }, { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 } @@ -730,7 +734,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48 -- Built as a TextBox `auto` table: auto.sound fires the frame the last -- page has typed out (PrintText returning), and auto.tick then runs once -- per frame while the gate it returns still reads as playing. -local function jigglypuffDance(game, npc) +local function jigglypuffDance(game, npc, ow) local Music = require("src.core.Music") -- .findMatchingFacingDirectionLoop: the rotation picks up at the entry -- matching the sprite's current facing (showMapText has just turned it @@ -776,7 +780,13 @@ local function jigglypuffDance(game, npc) if npc then npc.facing = JIGGLYPUFF_SPIN[step] end return end - if frames >= JIGGLYPUFF_TAIL then phase = "done" end + if frames >= JIGGLYPUFF_TAIL then + phase = "done" + if require("src.core.GameVersion").isYellow() + and require("src.world.PikachuFollower").starterInParty(game.save) then + ow.pikachuPewterSleepScene = true + end + end end, } end @@ -789,7 +799,7 @@ M.PEWTER_POKECENTER = { local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!", - done, { auto = jigglypuffDance(game, npc) })) + done, { auto = jigglypuffDance(game, npc, ow) })) end, }, } @@ -864,10 +874,11 @@ M.SILPH_CO_7F = { { "face_object", 9, "up" }, -- 4 { "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5 { "rival_battle", "OPP_RIVAL2", 7 }, -- 6 - { "jump_if_false", 12 }, -- 7 + { "jump_if_false", 13 }, -- 7 { "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8 { "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9 { "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10 + { "play_music", "Music_MeetRival", { start = "rival" } }, { "move_npc_to", 9, 5, y + 1 }, -- 11 { "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12 }, "down") @@ -899,10 +910,11 @@ M.SS_ANNE_2F = { { "face_object", 2, onLeft and "down" or "right" }, -- 3 { "show_text", "_SSAnne2FRivalText" }, -- 4 { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 11 }, -- 6 + { "jump_if_false", 12 }, -- 6 { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 + { "play_music", "Music_MeetRival", { start = "rival" } }, { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10 { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11 }, onLeft and "up" or "left") diff --git a/docs/launcher.md b/docs/launcher.md index 2a0e2ab6..aa087e48 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only duplicate) and returns one row per mod: `{id, name, version, badge, description, enabled, status, statusDetail}`. `badge` is the manifest's `category`, falling back to `profile`, then - `"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means - enabled, matching the loader's own default). + `"MOD"`, uppercased. `enabledByVersion` contains an answer for each game; + missing entries default to enabled (except experimental mods), matching the + loader. On the first run with per-game controls, legacy shared choices are + copied to every installed game's answer. - `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure `LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle` and the validated manifests: `conflict` when enabling this mod collides with another enabled one; `warn` for an out-of-range `game_version` or an absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no `love.*` calls, this half is table-driven by the test suite on its own. -- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain - boolean, the exact shape `Loader:_saveState` writes, so the running game - and the in-game `ManagerState` see the change on next boot. The mods panel - calls this on every toggle and re-derives the list right away +- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's + answer, so the running game and the in-game `ManagerState` see the change on + next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow, + and Gold on every row and re-derives the list right away (`RomImporter:_refreshMods`) so a status change (e.g. a new conflict) shows without waiting for a reload. - `LauncherMods.installZip(path)` mounts the archive with diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 3dd8fe0e..62ec6316 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard dependency does not run on the selected game reads `Needs (not for Gold)`, matching the loader's contagious skip. -A separate overlay, `options.modsByVersion[version][id]`, is where a per-game -enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`, -so `SaveData.modScope` answers nil for every caller and the launcher panel, the -in-game manager and the loader all read *and* write the one shared -`options.mods` flag. Nothing consults the overlay for enablement until that -flips, which is deliberate: the overlay is plantable from an imported -`.g1rmodlist`, and a reader scoped differently from the writers would show a -mod set no boot would honour. +A separate overlay, `options.modsByVersion[version][id]`, holds each game's +enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox +for every installed mod, and the loader and in-game manager read the same +game-specific answer on the next boot. On the first launch after this feature, +the existing shared state is copied to every game, so a mod that was enabled +remains enabled everywhere; after that, changing one checkbox affects only +that game. New mods still default to enabled on every game (experimental mods +retain their explicit opt-in default). That is deliberate. Gold reimplements the battle engine, the overworld, the script VM and the save format, so a Gen 1 mod dropped into a Gold boot would diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 81c8e9ad..c64ae33b 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -260,18 +260,13 @@ selected game reads `Needs (not for Gold)` rather than `Ready`. ### One limit worth knowing -**Per-game enable flags are still a preview.** The overlay -`options.modsByVersion[version][id]` exists and every surface goes through -`SaveData.modEnabled` / `SaveData.setModEnabled`, but -`SaveData.PER_VERSION_MODS` is `false` -(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers -nil for every caller, so the launcher panel, the in-game manager *and* the -loader all read and write the one shared `options.mods` flag and the overlay is -not consulted for enablement anywhere. That matters because the overlay is -plantable from an imported `.g1rmodlist`: keeping every reader on the same -scope as every writer is what stops a stored per-game flag from showing a mod -set no boot would honour. Nothing about this affects a mod author; it affects -what a player can currently express. +**Enablement is per game.** The overlay +`options.modsByVersion[version][id]` is read and written through +`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game +manager, and loader. Existing shared settings are copied to every game the +first time this version sees the installed mods; from then on, each coloured +game checkbox changes only that game's next boot. Nothing about this affects a +mod author; it affects what a player can express. Targeting is a different question from enablement and *is* enforced per game, as above. The two do not share a switch. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 44a2fe23..679e2518 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -613,6 +613,7 @@ local function newBattle(game) self.phase = "intro" self.menuIndex = 1 self.moveIndex = 1 + self.playerMoveListIndex = 1 self.frame = 0 return self end @@ -929,6 +930,11 @@ function BattleState:sayNext(text) table.insert(self.queue, self.nextInsert, { text = text }) end +function BattleState:sayNextWaitSfx(text, sfx) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx }) +end + -- sayNext for a page that ends in `text_end` (see sayAuto) (#765) function BattleState:sayNextAuto(text, delay) self.nextInsert = (self.nextInsert or 0) + 1 @@ -1255,13 +1261,13 @@ function BattleState:updateQueue() -- 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 + if self:animationsOn() and (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 + elseif self:animationsOn() and anim and anim.sound then local Sound = require("src.core.Sound") if Sound.playMove then Sound.playMove(self.data, anim) @@ -1361,6 +1367,11 @@ function BattleState:updateQueue() self.current = nil end elseif not (item and item.choice) then + if item and item.waitForLearningSfx and not item.soundStarted then + item.soundStarted = true + self.waitingSound = item.waitForLearningSfx() + return true + end -- The page is typed out and waiting on the player: PromptText -- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll -- blinks it until A/B, so the arrow belongs on a finished page and not @@ -1746,6 +1757,7 @@ end local function sendOutMonCursors(self) self.menuIndex = 1 self.moveIndex = 1 + self.playerMoveListIndex = 1 end -- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move @@ -2044,8 +2056,10 @@ function BattleState:update(dt) if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil + self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves) else self.moveSwapIndex = self.moveIndex + self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves) end elseif input:wasPressed("b") then require("src.core.Sound").play(self.data, "Press_AB") @@ -2068,6 +2082,7 @@ function BattleState:update(dt) self.phase = "messages" self.afterQueue = "menu" else + self.playerMoveListIndex = self.moveIndex self:resolveTurn(mv) end end @@ -2423,11 +2438,9 @@ function BattleState:resolveTurn(playerAction) end local order if pFirst then - order = { { self.player, self.enemy, playerAction }, - { self.enemy, self.player, enemyAction } } + order = { { true, playerAction }, { false, enemyAction } } else - order = { { self.enemy, self.player, enemyAction }, - { self.player, self.enemy, playerAction } } + order = { { false, enemyAction }, { true, playerAction } } end self.phase = "messages" @@ -2435,7 +2448,9 @@ function BattleState:resolveTurn(playerAction) for _, entry in ipairs(order) do self:act(function() - self:executeAction(entry[1], entry[2], entry[3]) + local user = entry[1] and self.player or self.enemy + local target = entry[1] and self.enemy or self.player + self:executeAction(user, target, entry[2]) end) end self:act(function() self:endOfTurn() end) @@ -2945,7 +2960,7 @@ function BattleState:applyHitFx(hit) Sound.play(self.data, hit.sfx) end end - if not t or not self:animationsOn() then return end + if not t then return end if t == 1 then -- PredefShakeScreenVertically b=8: the window drops by b for 3 frames -- then home for 3, b counting down @@ -3839,9 +3854,12 @@ function BattleState:awardExp() participants, alive = 1, { self.player.mon } end local function applyShare(mon, split, announce) + local playerId = self.game.save.player and self.game.save.player.id + local traded = mon.otId ~= nil and playerId ~= nil + and mon.otId ~= playerId or mon.traded == true and mon.otId == nil local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", - split, mon.traded) + split, traded) -- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle -> -- Evolution.checkParty). B-cancel leaves the mon at/above threshold; -- without this gate it re-triggers after every later fight (#213). @@ -3865,7 +3883,7 @@ function BattleState:awardExp() local text = Strings.source("%s gained\n%d EXP. Points!") if announce == "expAll" then text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!") - elseif mon.traded then + elseif traded then text = Strings.source("%s gained\na boosted\v%d EXP. Points!") end self:sayNext(Strings(text, name, gained)) @@ -4129,15 +4147,17 @@ function BattleState:learnMove(mon, moveId) if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, - mdef.name)) + self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, + mdef.name), function() + return require("src.core.Sound").play(self.data, "Level_Up") + end) 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 self:uiNext(function() - return self:buildScreen("MoveLearnMenu", mon, moveId) + return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up") end) end @@ -5791,6 +5811,7 @@ function BattleState:drawTextArea() Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8) end Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) + Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) end end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 05c219d9..2f0f5e60 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.afterDamage then record.afterDamage(ctx, totalDealt) elseif moveInst.struggle then - -- struggle recoils even when its effect id resolves to no record - local recoil = math.max(1, math.floor(dmg / 2)) + local recoil = math.max(1, math.floor(totalDealt / 2)) battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user))) battle:applyDamage(user, recoil) end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index a96127fd..a283a68f 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -348,8 +348,9 @@ MoveEffects.primary = { target.disabledTurns = battle.rng(1, 8) local id = target.curMoves[slot].id -- _MoveWasDisabledText: "X's / MOVE was / disabled!" - return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target), - battle.data.moves[id].name) } + return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", + { TARGET = displayName(target), + ["RAM:wNameBuffer"] = battle.data.moves[id].name }) } end, SPLASH_EFFECT = function(battle) @@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx) return dist[r + 1] end --- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals --- that amount, so Counter would see the halved value +-- engine/battle/core.asm ApplyDamageToEnemyPokemon local function drainHalf(label, text) return function(ctx) - local heal = math.max(1, math.floor(ctx.rawDamage / 2)) + local heal = math.max(1, math.floor(ctx.totalDealt / 2)) ctx.battle.lastDamage = heal local mon = ctx.user.mon mon.hp = math.min(mon.stats.hp, mon.hp + heal) @@ -529,9 +529,8 @@ MoveEffects.full = { RECOIL_EFFECT = { afterDamage = function(ctx) - -- 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(ctx.rawDamage + -- engine/battle/move_effects/recoil.asm + local recoil = math.max(1, math.floor(ctx.totalDealt / (ctx.moveInst.struggle and 2 or 4))) ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user))) ctx.battle:applyDamage(ctx.user, recoil) diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 90dd8d74..9a217b65 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId) local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name or tostring(selectedMoveId) table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText", - "%s's\n%s is\ndisabled!", name(battler), shown)) + "%s's\n%s is\ndisabled!", { + USER = name(battler), ["RAM:wNameBuffer"] = shown, + })) return false, msgs end end diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index e215c176..6602517d 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize") -- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon -- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon -- table directly, so that is what these payloads carry. --- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is --- the cart's BattleRandom convention (0..n-1). Both keys are present. +-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi). +-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the +-- battle: `random` / `roller()` are BattleRandom for damage, accuracy, +-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream. local Runtime = require("src.mods.Runtime") -- The two battle lines that carry the cart's own `line` break: a marker-bearing -- literal has to stay reachable from a translation mod (#186, #245), which is @@ -88,6 +90,19 @@ local function rand(random, n) return math.random(n) - 1 end +-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi. +local function loveStyleRng(random) + return function(lo, hi) + if hi == nil then + local n = lo or 1 + if n < 1 then n = 1 end + return (rand(random, n) or 0) + 1 + end + if hi < lo then lo, hi = hi, lo end + return lo + (rand(random, hi - lo + 1) or 0) + end +end + -- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only -- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into -- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader @@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = { -- this wild battle is BATTLETYPE_ROAMING; the caller built `wild` -- through Roamers.beginBattle and reads Battle.roaming back to -- bank the beast's HP afterwards --- random(n) 0..n-1, injected so a test is deterministic +-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom) +-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random` function Battle.new(opts) opts = opts or {} local self = setmetatable({}, Battle) self.data = opts.data or {} self.random = opts.random or function(n) return rand(nil, n) end + -- Same stream as `random`, Gen 1 / love.math calling convention. + self.rng = opts.rng or loveStyleRng(self.random) self.party = opts.party or {} self.trainer = opts.trainer self.save = opts.save @@ -403,11 +421,8 @@ function Battle:endBattle(outcome) Runtime.emit("battle.ended", { battle = self, result = outcome }) end --- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the --- constructor (a headless test injects one, the game leaves it to love.math), --- and a mod reaching for ctx.rng must not have to know that. Note the --- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's --- love.math.random (1..n). +-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte. +-- `battle.rng` is the Gen 1 / love.math view of the same stream. function Battle:roller() if not self.rollerFn then self.rollerFn = function(n) return rand(self.random, n) end diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 0cecd019..00dc8c13 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -265,8 +265,18 @@ Effects.MAGNITUDE_POWER = { -- is what damagecalc reads as the move's power -- data/moves/moves.asm stores -- MAGNITUDE at power 1 precisely because this overwrites it. Returns the -- power and the magnitude number the text prints. +-- +-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math +-- / math.random — never hard-code 0 (that always yields Magnitude 4). function Effects.magnitudePower(random) - local roll = random and random(256) or 0 + local roll + if type(random) == "function" then + roll = random(256) or 0 + elseif love and love.math and love.math.random then + roll = love.math.random(256) - 1 + else + roll = math.random(256) - 1 + end for _, row in ipairs(Effects.MAGNITUDE_POWER) do if row[1] >= roll then return row[2], row[3] end end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1cc77f6b..e21500e5 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1230,8 +1230,17 @@ function Engine.new(data, header, options) engine.tempo = header.tempo engine.tempoLocked = true end - for _, spec in ipairs(chip and chip.channels - or headerChannels(banks, header)) do + local channels = chip and chip.channels or headerChannels(banks, header) + if header.startChannels then + local byNumber = {} + for _, start in ipairs(header.startChannels) do + byNumber[start.number] = start.address + end + for _, spec in ipairs(channels) do + spec.address = byNumber[spec.number] or spec.address + end + end + for _, spec in ipairs(channels) do local frameTicks = options.frameTicks local hardware = (spec.number - 1) % 4 + 1 if hardware == 4 then diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4ce17192..8e02f801 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -689,10 +689,26 @@ function Game2:usePartyItem(itemId) self:say(result.text) return end - self:consumeItem(itemId) - if action == "candy" then + if action == "stone" then + local party = (self.save and self.save.party) or {} + local index + for i, member in ipairs(party) do + if member == mon then index = i break end + end + Screens.push(self, "Gen2EvolutionAnim", { + mon = mon, entry = result.evolution, index = index, + party = party, save = self.save, + force = true, + onDone = function(evolution) + if evolution and evolution.evolved then self:consumeItem(itemId) end + self.stack:pop() + end, + }) + elseif action == "candy" then + self:consumeItem(itemId) self:say(result.text, function() self:afterRareCandy(mon, result) end) else + self:consumeItem(itemId) self:say(result.text) end end diff --git a/src/core/Music.lua b/src/core/Music.lua index 2c3523e9..2c5f7b1f 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -64,6 +64,7 @@ state = { fanfareResume = false, -- start/resume state.source when the fanfare ends fade = nil, -- active volume-ramp fade-out (see Music.fadeOut) tempo = nil, -- alternate-tempo override in force for `current` + start = nil, failed = {}, -- labels whose def could not be started; logged once } @@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx) song = selectSong(song, ctx) local tempo = ctx and ctx.tempo or nil + local start = ctx and ctx.start or nil -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against - if not song or (song == state.current and tempo == state.tempo) then return end + if not song or (song == state.current and tempo == state.tempo + and start == state.start) then return end local def = songDef(data, song) if not def or state.failed[song] then return end @@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx) slowed.tempo = tempo def = slowed end + if start == "rival" and song == "Music_MeetRival" + and def.bank == 2 and def.address == 17050 then + local started = {} + for key, value in pairs(def) do started[key] = value end + started.startChannels = { + { number = 1, address = 0x71a2 }, + { number = 2, address = 0x721d }, + { number = 3, address = 0x72b5 }, + } + def = started + end local wantLoop = loop ~= false local src, loopSrc, isChip, err = startSong(data, def, wantLoop) if not src then @@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx) state.source, state.loopSource, state.chip = src, loopSrc, isChip state.current = song state.tempo = tempo + state.start = start if Runtime.wants("music.started") then Runtime.emit("music.started", { song = song, previous = previous, chip = isChip, @@ -290,7 +305,7 @@ function Music.stop() stopSource(state.loopSource) require("src.core.ChipAudio").stopMusic() state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil - state.tempo = nil + state.tempo, state.start = nil, nil state.chip = false state.pendingRestore = nil if previous and Runtime.wants("music.stopped") then diff --git a/src/core/RomText.lua b/src/core/RomText.lua index ef15eb81..5274bd46 100644 --- a/src/core/RomText.lua +++ b/src/core/RomText.lua @@ -33,6 +33,14 @@ return function(data, label, fallback, ...) local args = { ... } if #args == 0 then return text end + if #args == 1 and type(args[1]) == "table" then + local values = args[1] + return (text:gsub("%b{}", function(token) + local value = values[token] or values[token:sub(2, -2)] + return value == nil and token or tostring(value) + end)) + end + local slots, named = 0, 0 for token in text:gmatch("%b{}") do slots = slots + 1 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b3873497..3f52375d 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -304,6 +304,10 @@ function SaveData.defaultOptions() -- options.lua written before this key keeps its exact meaning. Read and -- written through SaveData.modEnabled / SaveData.setModEnabled. modsByVersion = {}, + -- Set after the first per-game enablement migration. Older options files + -- have only `mods`, so the migration copies each installed mod's current + -- answer to every game before game-specific toggles begin changing it. + modsByVersionMigrated = false, -- Named setups the player can switch between (#593; src/mods/ModProfile.lua -- owns the shape, src/mods/ManagerState.lua the UI): each row is -- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }. @@ -563,19 +567,68 @@ end -- only holds the games the player actually chose for, so a mod set can differ -- between Red and Gold without either one owning the other's flags. --- Whether a per-game answer is honoured at boot. The loader reads the enable --- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this --- flips on with that read and not before: until then every writer keeps to the --- shared flag and no surface promises what the boot does not do. -SaveData.PER_VERSION_MODS = false +-- Per-game answers are live. Every reader and writer goes through modScope, +-- so a choice made in the launcher is the choice the next boot loads. +SaveData.PER_VERSION_MODS = true --- The version a write should be scoped to: the game asked for once per-game --- flags are live, nil (the shared flag) while they are only a preview. +-- The version a write is scoped to: per-game controls name one game; a nil +-- caller still addresses the legacy shared fallback. function SaveData.modScope(version) if SaveData.PER_VERSION_MODS then return version end return nil end +-- Promote an installation that predates per-game flags. `mods` may contain +-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry +-- had the loader default: on, except for experimental mods. Copy that answer +-- to every game once, preserving any per-game overlay somebody imported before +-- this feature shipped. New installs need no rows here: an absent answer +-- still defaults to enabled for every game. +function SaveData.migrateModEnablement(options, mods) + if type(options) ~= "table" or options.modsByVersionMigrated then return false end + options.mods = type(options.mods) == "table" and options.mods or {} + options.modsByVersion = type(options.modsByVersion) == "table" + and options.modsByVersion or {} + + local known = {} + for id in pairs(options.mods) do + if type(id) == "string" and id ~= "" then known[id] = { id = id } end + end + for version, bucket in pairs(options.modsByVersion) do + if GameVersion.VERSIONS[version] and type(bucket) == "table" then + for id in pairs(bucket) do + if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end + end + end + end + for _, mod in ipairs(mods or {}) do + local id = type(mod) == "table" and mod.id or mod + if type(id) == "string" and id ~= "" then + known[id] = type(mod) == "table" and mod or (known[id] or { id = id }) + end + end + + -- Do not create options.lua just to record an empty migration on a fresh + -- no-mod boot. Keep it pending until there is a real installed or saved + -- mod answer to preserve. + if next(known) == nil then return false end + + for id, mod in pairs(known) do + local shared = options.mods[id] + if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end + for _, version in ipairs(GameVersion.ORDER) do + local bucket = options.modsByVersion[version] + if type(bucket) ~= "table" then + bucket = {} + options.modsByVersion[version] = bucket + end + if type(bucket[id]) ~= "boolean" then bucket[id] = shared end + end + end + options.modsByVersionMigrated = true + return true +end + -- true/false as chosen for `version`, else the shared flag, else nil -- the -- caller owns the default (the loader enables, the launcher keeps -- experimental mods off until asked). @@ -1834,6 +1887,8 @@ function SaveData.newGame(boot) boot = type(boot) == "table" and boot or {} local map = boot.startMap or "REDS_HOUSE_2F" local x, y = boot.startX or 3, boot.startY or 6 + local facing = boot.startFacing or "down" + if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end local heal = SaveData.defaultHeal(boot) local save = { meta = { format = Version.saveFormat, mods = {} }, @@ -1844,7 +1899,7 @@ function SaveData.newGame(boot) map = map, x = x, y = y, - facing = boot.startFacing or "down", + facing = facing, name = boot.playerName or "RED", rival = boot.rivalName or "BLUE", -- 16-bit trainer ID rolled at new game (wPlayerID, filled from diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua index 7095ada9..c57010dc 100644 --- a/src/core/gen2/ItemEffects.lua +++ b/src/core/gen2/ItemEffects.lua @@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx) return rareCandy(ctx.mon, ctx.data) end) +for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE", + "THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do + record(itemId, "stone", function(ctx) + if ctx.mon.item == "EVERSTONE" then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + local Evolution = require("src.core.gen2.Evolution") + local entry = Evolution.checkMon(ctx.data, ctx.mon, + { force = true, item = ctx.item }) + if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + return { used = true, evolution = entry } + end) +end + for itemId in pairs(ItemEffects.REVIVE) do record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end) end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index b5f35c8a..706af73b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -100,6 +100,7 @@ end -- other half of that guarantee. function LauncherView.update(imp, dt) if not imp._flex then return end + if imp._launchFade then return end local down = false if love.mouse and love.mouse.isDown then @@ -312,6 +313,236 @@ local function cartColor(version) return CART_COLOR[version] or PAL.green end +local CART_DRAG_SLOP = 8 +local TAU = math.pi * 2 + +local function cartridgeState(imp, version) + imp._cartridge = imp._cartridge or {} + local state = imp._cartridge[version] + if not state then + state = { spin = 0, lastTime = Kit.time } + imp._cartridge[version] = state + end + return state +end + +local function cartridgeLabel(imp, version) + imp._cartridgeLabels = imp._cartridgeLabels or {} + local label = imp._cartridgeLabels[version] + if label ~= nil then return label or nil end + local ok, image = pcall(love.graphics.newImage, + "assets/labels/" .. tostring(version) .. ".png") + if not ok then + imp._cartridgeLabels[version] = false + return nil + end + local iw, ih = image:getDimensions() + label = { image = image, width = iw, height = ih } + imp._cartridgeLabels[version] = label + return label +end + +local function cartProject(cx, cy, yaw, pitch, x, y, z) + local cyaw, syaw = math.cos(yaw), math.sin(yaw) + local cpitch, spitch = math.cos(pitch), math.sin(pitch) + local rx = x * cyaw + z * syaw + local rz = -x * syaw + z * cyaw + local ry = y * cpitch - rz * spitch + rz = y * spitch + rz * cpitch + local perspective = 620 / (620 - rz) + return cx + rx * perspective, cy + ry * perspective +end + +local function cartPolygon(points, color, alpha) + local flat = {} + for i = 1, #points do + flat[#flat + 1], flat[#flat + 2] = points[i][1], points[i][2] + end + Theme.col(color, alpha or 1) + love.graphics.polygon("fill", flat) +end + +local function cartQuad(project, x, y, w, h, z) + return { + { project(x, y, z) }, { project(x + w, y, z) }, + { project(x + w, y + h, z) }, { project(x, y + h, z) }, + } +end + +local function cartPill(project, x, y, w, h, z, color, alpha) + local points, radius = {}, h / 2 + for i = 0, 10 do + local a = math.pi + math.pi * i / 10 + points[#points + 1] = { project(x + radius + math.cos(a) * radius, + y + radius + math.sin(a) * radius, z) } + end + for i = 0, 10 do + local a = math.pi * i / 10 + points[#points + 1] = { project(x + w - radius + math.cos(a) * radius, + y + radius + math.sin(a) * radius, z) } + end + cartPolygon(points, color, alpha) +end + +local function cartLabelMesh(imp, version, label, points) + if not love.graphics.newMesh then return nil end + imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {} + local mesh = imp._cartridgeLabelMeshes[version] + if not mesh then + mesh = love.graphics.newMesh({ + { 0, 0, 0, 0, 255, 255, 255, 255 }, + { 0, 0, 1, 0, 255, 255, 255, 255 }, + { 0, 0, 1, 1, 255, 255, 255, 255 }, + { 0, 0, 0, 1, 255, 255, 255, 255 }, + }, "fan", "dynamic") + mesh:setTexture(label.image) + imp._cartridgeLabelMeshes[version] = mesh + end + mesh:setVertices({ + { points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 }, + { points[2][1], points[2][2], 1, 0, 255, 255, 255, 255 }, + { points[3][1], points[3][2], 1, 1, 255, 255, 255, 255 }, + { points[4][1], points[4][2], 0, 1, 255, 255, 255, 255 }, + }) + return mesh +end + +local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) + local state = cartridgeState(imp, version) + local focused = Kit.focusable(key, x, y, w, h) + local hot = Kit.hover(x, y, w, h) + local active = state.active + local cx, cy = x + w / 2, y + h / 2 + + if Kit.mouseClicked and Kit.hit(x, y, w, h) and not Kit.blockClicks then + if Kit.mouseDown then + state.active = true + state.startX, state.startY = Kit.mouseX, Kit.mouseY + state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY + state.dragged = false + active = true + else + queueAction(imp, key, action) + end + end + + if state.active then + active = true + if Kit.mouseDown then + local movedX, movedY = Kit.mouseX - state.startX, Kit.mouseY - state.startY + if movedX * movedX + movedY * movedY > CART_DRAG_SLOP * CART_DRAG_SLOP then + state.dragged = true + end + if state.dragged then + local dragX = Kit.mouseX - (state.lastDragX or Kit.mouseX) + local dragY = Kit.mouseY - (state.lastDragY or Kit.mouseY) + state.spin = state.spin + dragX * 0.018 + state.pitchDrag = clamp((state.pitchDrag or 0) + dragY * 0.010, + -1.20, 1.20) + end + state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY + else + if not state.dragged then queueAction(imp, key, action) end + state.active, active = nil, false + state.dragged = nil + end + end + + local dt = math.min(0.08, math.max(0, Kit.time - (state.lastTime or Kit.time))) + state.lastTime = Kit.time + if not state.active then + local upright = math.floor(state.spin / TAU + 0.5) * TAU + state.spin = state.spin + (upright - state.spin) * math.min(1, dt * 4) + state.pitchDrag = (state.pitchDrag or 0) + * (1 - math.min(1, dt * 4)) + end + local pointerX = clamp((Kit.mouseX - cx) / math.max(1, w / 2), -1, 1) + local pointerY = clamp((Kit.mouseY - cy) / math.max(1, h / 2), -1, 1) + local hoverTilt = hot and pointerX * 0.12 or math.sin(Kit.time * 0.75) * 0.035 + local pressX = active and pointerX * w * 0.025 or 0 + local pressY = active and pointerY * h * 0.018 or 0 + local yaw = -0.42 + state.spin + hoverTilt + local pitch = 0.14 + (state.pitchDrag or 0) + + (hot and pointerY * 0.08 or math.sin(Kit.time * 0.6) * 0.018) + local pressedScale = active and 0.965 or 1 + + Kit._audit("control", x, y, w, h, key) + if focused then + Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.lineStrong, + Theme.A.focus, 2, Theme.cardRadius() + 2) + end + + local halfW, halfH = w / 2, h / 2 + local depth = math.max(8, w * 0.14) + local project = function(px, py, pz) + return cartProject(cx + pressX, cy + pressY, yaw, pitch, + px * pressedScale, py * pressedScale, pz * pressedScale) + end + + local capH = h * 3 / 65 + local mainTop = -halfH + capH + local capRight = halfW - w * 5 / 57 + local mainFront = cartQuad(project, -halfW, mainTop, w, h - capH, depth) + local mainBack = cartQuad(project, -halfW, mainTop, w, h - capH, -depth) + local capFront = cartQuad(project, -halfW, -halfH, + capRight + halfW, capH, depth) + local capBack = cartQuad(project, -halfW, -halfH, + capRight + halfW, capH, -depth) + local shell = cartColor(version) + local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54), + math.floor(shell[3] * 0.54) } + + cartPolygon(mainBack, side, 1) + cartPolygon(capBack, side, 1) + cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1) + cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1) + cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1) + cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1) + cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1) + cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1) + cartPolygon(mainFront, shell, 1) + cartPolygon(capFront, shell, 1) + + local faceZ = depth + 0.8 + for i = 0, 4 do + local ry = mainTop + 7 + i * h * 0.025 + cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7) + cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7) + end + local recessX, recessY = -w * 0.32, mainTop + h * 0.023 + local recessW, recessH = w * 0.64, h * 0.24 + cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88) + cartPill(project, recessX + w * 0.025, recessY + h * 0.025, + recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7) + cartPill(project, recessX + w * 0.045, recessY + h * 0.043, + recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42) + + local labelX, labelY = -w * 0.33, -h * 0.20 + local labelW, labelH = w * 0.66, h * 0.55 + local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8) + cartPolygon(plate, side, 0.95) + local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2) + local label = cartridgeLabel(imp, version) + local mesh = label and cartLabelMesh(imp, version, label, labelPoints) + if mesh then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(mesh) + elseif label then + local artScale = math.min(labelW / label.width, labelH / label.height) + love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2], + 0, artScale, artScale) + end + cartPolygon({ + { project(-w * 0.07, h * 0.37, faceZ + 1) }, + { project(w * 0.07, h * 0.37, faceZ + 1) }, + { project(0, h * 0.43, faceZ + 1) }, + }, side, 0.70) + + if not state.active and (Kit._activateId == key) then + queueAction(imp, key, action) + end +end + local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end if status == "conflict" then return Strings("Conflict"), PAL.red end @@ -1011,8 +1242,16 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) local tagH = Kit.textHeight("micro") + math.floor(10 * m.s) - Kit.tag(x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6)) - + math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol) + local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6)) + + math.floor(12 * m.s) + Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol) + if ready then + local hint = Strings("(PRESS THE CART TO PLAY)") + local hintX = tagX + tagW + math.floor(10 * m.s) + local hintW = math.max(0, x + w - hintX) + Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX, + y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading) + end local cy = y + titleH + math.floor(12 * m.s) local remaining = availH - (titleH + math.floor(12 * m.s)) @@ -1038,25 +1277,20 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) local ly = cy if ready then - -- Play IS the panel: it takes the space the ROM buttons used to hold, at - -- the top of the column where the eye lands, wearing this game's own - -- cartridge colour rather than a generic green. - -- Play grows into the room the column has: it is the one thing on this - -- screen the player came for, and the space freed by moving ROM and - -- control management out belongs to it rather than to a gap. Clamped at - -- both ends so a short window still gets a real button and a tall one - -- does not get a billboard. - local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s)) - local mgW = playH + -- The cartridge takes the Play button's former place. Its portrait + -- ratio comes from a real Game Boy cart rather than stretching the old + -- horizontal control, and its body colour comes from the active game. + local playH = math.floor(clamp(remaining * 0.52, 112 * m.s, 260 * m.s)) + local mgW = math.max(Kit.tapMin(), math.floor(34 * m.s)) local bgap = math.floor(8 * m.s) - btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version, - Strings("Play ") .. gameName, { - fill = cartColor(version), ink = PAL.inverse, font = "stat", - action = function() imp:play(version) end, - }) + local cartAreaW = lw - mgW - bgap + local cartW = math.min(cartAreaW, math.floor(playH * 0.88)) + local cartX = lx + math.floor((cartAreaW - cartW) / 2) + cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version, + version, gameName, function() imp:play(version, true) end) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") - iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH, + iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW, imp._gearIcon, function() imp._gameManage = version end) ly = ly + playH + gap end @@ -1139,6 +1373,28 @@ local function drawCheck(x, y, size, color) love.graphics.pop() end +-- One compact coloured checkbox for each game. The cartridge colour carries +-- the game identity even when the row is narrow; the letter keeps an unchecked +-- box legible without relying on colour alone. +local function modGameCheckbox(x, y, size, checked, game, id) + local color = cartColor(game) + local focused = Kit.focusable(id, x, y, size, size) + local hot = focused or Kit.hover(x, y, size, size) + if love.graphics then + if checked then + Theme.fillRounded(x, y, size, size, color, 1) + drawCheck(x, y, size, PAL.inverse) + else + Theme.fillRounded(x, y, size, size, PAL.bg, 1) + Kit.textCenterBold("micro", game:sub(1, 1):upper(), x, + y + (size - Kit.textHeight("micro")) / 2, size, color) + end + Theme.strokeRounded(x, y, size, size, color, + hot and Theme.A.focus or Theme.A.hover, 1) + end + return Kit.press(x, y, size, size) or Kit._activateId == id +end + local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") @@ -1225,15 +1481,14 @@ local function buildModsPanel(imp, x, y, w, availH, m) mods = sorted end - -- A mod row is a fixed height: name line, version + status line, one line - -- of description, and an action row. Fixed because a page of uniform rows - -- is what lets perPage come from the viewport. - local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) - -- Text block on the left, chips right-aligned beside it: one row, not a - -- text block with a button strip stacked under it. + -- A mod row is a fixed height: its details first, then a dedicated second + -- line of per-game checkboxes. Fixed because a page of uniform rows is + -- what lets perPage come from the viewport. + local togH = math.floor(26 * m.s) + local gamesLabel = Strings("Enable for:") local textH = Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") - local rowH = math.floor(8 * m.s) + math.max(textH, chipH) + local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listH = availH - (cy - y) - pagerH - gap @@ -1258,28 +1513,36 @@ local function buildModsPanel(imp, x, y, w, availH, m) local px, inner = x + pad, w - 2 * pad local ly = ry + math.floor(10 * m.s) - local togW = math.floor(56 * m.s) - local togH = math.floor(26 * m.s) + local togGap = math.floor(4 * m.s) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - local togKey = "mod-toggle-" .. mod.id - -- The toggle reports its own new value, but the importer owns the state: - -- queue the flip and let _toggleMod (which may raise an experimental-mod - -- confirm) decide what actually happens. - local _, flipped = Kit.toggle(px + inner - togW, - ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey) - if flipped then - queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end) + -- These answer separate games, not a single shared install flag. The + -- importer receives the game id so an experimental confirmation also + -- applies only to the checkbox the player pressed. + local flipped = false + local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) + Kit.text("micro", gamesLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) + local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true + end + tx = tx + togH + togGap end - -- The toggle sits inside the row's rect, so its press also passes the - -- row's hit test; `flipped` gates the row action to everywhere else. + -- The checkboxes sit inside the row's rect, so their press also passes the + -- row hit test; `flipped` gates the row action to everywhere else. if not flipped and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then local id = mod.id queueAction(imp, rowKey, function() imp._modActions = id end) end - local chipsW = togW + math.floor(6 * m.s) - local textW = inner - chipsW - math.floor(12 * m.s) + local textW = inner local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) -- the games the mod is for, beside its category: the same chip the @@ -1741,7 +2004,7 @@ local function buildConfirmModal(imp, m) elseif c.kind == "importOversize" then imp:_importSave(c.version, c.source, true) else - imp:_toggleMod(c.id, true) + imp:_toggleMod(c.id, true, c.version) end end, }) @@ -2608,6 +2871,11 @@ function LauncherView.draw(imp) end end + if imp._launchFade then + Theme.fill(0, 0, m.W, m.H, PAL.bg, + math.min(1, imp._launchFade.elapsed / imp._launchFade.duration)) + end + Kit.endFrame() drawPadCursor(imp) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9984768c..15fa5c25 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2048,6 +2048,15 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt + if self._launchFade then + self._launchFade.elapsed = self._launchFade.elapsed + dt + if self._launchFade.elapsed >= self._launchFade.duration then + local version = self._launchFade.version + self._launchFade = nil + self:play(version) + return + end + end self:_updatePadCursor(dt) self:_stepBaseRomScan() -- Pump the FlexLove view (input polling + the queued click actions). The @@ -2436,9 +2445,15 @@ function RomImporter:joystickhat(joystick, hat, direction) end -- Player pressed Play on a game whose ROM is imported: hand off to boot. -function RomImporter:play(version) +function RomImporter:play(version, fade) if self.workState == "working" then return end if not self.ready[version] then return end + if fade then + if not self._launchFade then + self._launchFade = { version = version, elapsed = 0, duration = 0.24 } + end + return + end self._handedOff = true -- #835: remember the game being launched so the next launcher start opens on -- its column (_applyLastVersionTab). It rides options.lua rather than a file @@ -3064,15 +3079,19 @@ function RomImporter:_modUpdateInfo(id) return self.modUpdateInfo and self.modUpdateInfo[id] or nil end --- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist --- so the toggle, count, and every status chip reflect the new resolution. --- Enabling an experimental mod arms a confirm first. -function RomImporter:_toggleMod(id, confirmed) +-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist +-- so that game's checkbox and status chips reflect the new resolution. +-- Enabling an experimental mod arms a confirmation for that same game. +function RomImporter:_toggleMod(id, confirmed, version) local LauncherMods = require("src.mods.LauncherMods") local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do if m.id == id then - cur = m.enabled + if version and m.enabledByVersion then + cur = m.enabledByVersion[version] == true + else + cur = m.enabled + end experimental = m.experimental == true break end @@ -3080,7 +3099,7 @@ function RomImporter:_toggleMod(id, confirmed) local want = not cur if want and experimental and not confirmed then self._modConfirm = { - kind = "experimental", id = id, + kind = "experimental", id = id, version = version, title = "Experimental mod", yesLabel = "Enable", lines = { @@ -3092,7 +3111,7 @@ function RomImporter:_toggleMod(id, confirmed) return end self._modConfirm = nil - LauncherMods.setEnabled(id, want, self.modScope) + LauncherMods.setEnabled(id, want, version or self.modScope) self:_refreshMods() end diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 2dd49e1d..dfc34125 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed. if itemId == "POKE_FLUTE" then if not battle then + if ow and ow.map and ow.map.id == "PEWTER_POKECENTER" + and ow.pikachuPewterSleepScene then + local Follower = require("src.world.PikachuFollower") + local pika = Follower.current(ow) + local player = ow.player + if pika and player + and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then + return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText", + "{PLAYER} played the\nPOKé FLUTE.") } + end + end -- 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 diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index af091c7d..c35adaf2 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") local CacheFs = require("src.import.CacheFs") local LauncherMods = {} @@ -107,8 +108,9 @@ end -- deriveList(manifests, options [, version]) -> the panel row list, pure. -- manifests is an array of validated manifests (Manifest.validate output); -- options is the options table (options.mods, options.modsByVersion and --- options.modsGen2 are read). `version` is the game the panel is showing: --- nil keeps the pre-per-game view, where the shared flag is the whole answer. +-- options.modsGen2 are read). `version` is the game the panel is showing; +-- each row also carries its answer for every game so the launcher can render +-- the coloured game checkboxes together. -- Rows come back sorted by id so the panel order is stable. function LauncherMods.deriveList(manifests, options, version) local ordered = {} @@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version) -- this game's choice, then the shared flag, then the default: enabled, -- matching the loader -- except experimental mods, which stay off until -- the player opts in. Scoped through modScope, so this reads exactly what - -- setEnabled writes and the loader loads: while per-game flags are a - -- preview the shared flag is the whole answer on every surface. + -- setEnabled writes and the loader loads for the selected game. local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) if decided == nil then decided = not m.experimental end if decided then enabledSet[m.id] = true end @@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version) badge = badge, description = m.description or "", enabled = enabled, + enabledByVersion = (function() + local answers = {} + for _, game in ipairs(GameVersion.ORDER) do + local answer = SaveData.modEnabled(options, m.id, game) + answers[game] = answer == true or (answer == nil and not m.experimental) + end + return answers + end)(), status = status, statusDetail = detail, github = m.github, @@ -281,7 +290,14 @@ end function LauncherMods.list(version) local ok, result = pcall(function() local options = SaveData.loadOptions() - return LauncherMods.deriveList(discover(), options, version) + local manifests = discover() + -- The first build containing game-specific switches turns the old shared + -- state into one explicit answer per installed mod and game. Saving here + -- means users who only visit the launcher still receive the migration. + if SaveData.migrateModEnablement(options, manifests) then + SaveData.saveOptions(options) + end + return LauncherMods.deriveList(manifests, options, version) end) if not ok then -- a single bad options/mod file must not blank the launcher @@ -364,10 +380,8 @@ function LauncherMods.translationStrings() return merged end --- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact --- shape Loader:_saveState writes (a plain boolean), so the running game and --- the in-game ManagerState pick it up unchanged. With `version` the choice --- lands in that game's overlay instead and no other game moves. +-- setEnabled(id, enabled [, version]): with a game, persist just that game's +-- answer. The loader and the in-game manager use the same scope on next boot. function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) @@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version) local options = SaveData.loadOptions() local scope = SaveData.modScope(version) for _, id in ipairs(ids or {}) do - SaveData.setModEnabled(options, id, enabled, scope) + if scope then + SaveData.setModEnabled(options, id, enabled, scope) + elseif SaveData.PER_VERSION_MODS then + for _, game in ipairs(GameVersion.ORDER) do + SaveData.setModEnabled(options, id, enabled, game) + end + else + SaveData.setModEnabled(options, id, enabled) + end end SaveData.saveOptions(options) return true @@ -843,7 +865,7 @@ end -- uninstall(id) -> true | nil, errString -- Removes mods// from wherever it was installed (the portable game folder --- or the save directory, CacheFs decides -- #330) and clears options.mods[id] +-- or the save directory, CacheFs decides -- #330) and clears every enable flag -- so the loader and in-game manager no longer see it. Rejects missing ids. -- Does not touch other mods' enable state. function LauncherMods.uninstall(id) @@ -872,8 +894,19 @@ function LauncherMods.uninstall(id) -- Drop the enable flag so a reinstall of the same id starts from the -- loader's default (enabled) rather than a stale false. local options = SaveData.loadOptions() + local changed = false if options.mods and options.mods[id] ~= nil then options.mods[id] = nil + changed = true + end + for _, version in ipairs(GameVersion.ORDER) do + local bucket = options.modsByVersion and options.modsByVersion[version] + if type(bucket) == "table" and bucket[id] ~= nil then + bucket[id] = nil + changed = true + end + end + if changed then SaveData.saveOptions(options) end return true diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 6c446095..f1b1561c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -270,8 +270,7 @@ function Loader:_targetVersion() return version end --- The version an enable flag is read and written under: this game once --- per-game flags are live, nil (the shared flag) while they are a preview. +-- The version an enable flag is read and written under: this running game. -- Reads and writes go through the same answer so the two can never drift. function Loader:_enableScope() return SaveData.modScope(self:_targetVersion()) @@ -1306,13 +1305,30 @@ function Loader:load(data) require("src.mods.Builtins").install(self.content, data, self.generation) self:_loadState() self:_discover() + -- Existing installs stored one shared answer. Once their manifests are + -- known, split that answer across every game before the next launcher/game + -- toggle can change one independently. _loadState already used the same + -- fallback, so this write cannot change the current boot's result. + do + local options = SaveData.loadOptions(self.fs) + local installed = {} + for id, mod in pairs(self.mods) do + installed[#installed + 1] = { + id = id, + experimental = mod.manifest and mod.manifest.experimental == true, + } + end + if SaveData.migrateModEnablement(options, installed) and self.fs.write then + SaveData.saveOptions(options, self.fs) + end + end -- Experimental mods stay off until the player opts in: a missing -- options.mods entry normally means enabled, but experimental flips that. do local options = SaveData.loadOptions(self.fs) - local modsOpt = options.mods or {} + local scope = self:_enableScope() for id, mod in pairs(self.mods) do - if not self.disabled[id] and modsOpt[id] == nil + if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil and mod.manifest.experimental then self.disabled[id] = true end diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 40cf0ab7..d1f12591 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m) }, apply) end --- Where the loader persisted an enable flag: this game's slot once it keeps --- them per game, the shared flag until then (SaveData.modScope). +-- Where the loader persists an enable flag: this running game's slot. function ManagerState:enableScope() return SaveData.modScope((self:targetGame())) end diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index b92352b6..83b6a1d6 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -140,6 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) + require("src.core.Sound").play(game.data, "Get_Item1") game.stack:push(TextBox.new(game, romText(game.data, "_LearnedMove1Text", "%s learned\n%s!", name, mdef.name), nextStep)) diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 0accd16b..94909836 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -62,6 +62,8 @@ local function getObpImage(path, colors, group) return obpCache[key] end +SpriteRenderer.obpImage = getObpImage + -- hot reload drops the sheets; live instances hold their own image, so -- the world rebuilds them (MapLoader.invalidateAll) rather than this function SpriteRenderer.invalidate() @@ -281,7 +283,7 @@ end -- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional -- and trailing, so every existing call site is unchanged. function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, - topHalf, forceFlip) + topHalf, forceFlip, frameOverride) local x, y = self:getScreenOrigin(px, py, camX, camY) local image = self.image local redraw = false @@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, -- still 3-frame sprites turn to face (the nurse at her machine, -- facePlayer on STAY NPCs) but never show walk frames. local frame, flip = pose(self, facing, walkPhase, stepFlip) + if frameOverride and self.frames[frameOverride] then + frame, flip = frameOverride, false + end if forceFlip then flip = true end local quad = self.frames[frame] local drawHeight = self.frameHeight diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 861cf774..67ef8910 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -130,6 +130,7 @@ TextBox.TOKENS = { STRBUF = function(game) return game.stringBuffer end, RAM = function(game, arg) if arg == "wStringBuffer" then return game.stringBuffer end + if arg == "wNameBuffer" then return game.stringBuffer end if arg == "wBoxNumString" then return game.boxNumString end -- SendNewMonToBox / _SentToBoxText reads the deposited nick here if arg == "wBoxMonNicks" then return game.boxMonNicks end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 42333c21..d7319953 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_ -- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute -- 0x2852 (#763, #857). O.toggleObjectFlags = O.coins + 2 -- 32B +O.hiddenItemFlags = O.townVisited - 27 -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified @@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts) end end + if data.hiddenItems then + save.hiddenTaken = {} + for i, row in ipairs(data.hiddenItems) do + if bitGet(bytes, O.hiddenItemFlags, i - 1) then + save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true + end + end + end + -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index: -- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- into de and rotates it right one bit per iteration with b counting up @@ -953,6 +963,14 @@ function GenSave.encode(save, data, template) end end + if data.hiddenItems then + local taken = save.hiddenTaken or {} + for i, row in ipairs(data.hiddenItems) do + local key = row[1] .. "_" .. row[2] .. "_" .. row[3] + bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false) + end + end + -- FLY destinations back into wTownVisitedFlag (see the decode note), so a -- save exported from this port is flyable on hardware (#263). A save -- table with no `visited` key at all says nothing about the set, so leave diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 262f10de..476b4794 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -49,6 +49,7 @@ local DATA_MODULES = { charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, + hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" }, } local OPTIONAL_MODULES = { tilesets = true, audio = true } @@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = { "src.save_convert.data.event_flags_yellow", "src/save_convert/data/event_flags_yellow.lua", } +local YELLOW_HIDDEN_ITEMS = { + "src.save_convert.data.hidden_items_yellow", + "src/save_convert/data/hidden_items_yellow.lua", +} local function loadTable(requirePath, filePath) local ok, mod = pcall(require, requirePath) @@ -133,6 +138,8 @@ local function ensureData(gameVersion) if name ~= "charmap" then if name == "eventFlags" and gameVersion == "yellow" then spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838) + elseif name == "hiddenItems" and gameVersion == "yellow" then + spec = YELLOW_HIDDEN_ITEMS end local mod = loadCacheTable(gameVersion, spec[2]) if not mod then diff --git a/src/save_convert/data/hidden_items.lua b/src/save_convert/data/hidden_items.lua new file mode 100644 index 00000000..0c893a29 --- /dev/null +++ b/src/save_convert/data/hidden_items.lua @@ -0,0 +1,30 @@ +return { + { "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 }, + { "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 }, + { "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 }, + { "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 }, + { "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 }, + { "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 }, + { "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 }, + { "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 }, + { "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 }, + { "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 }, + { "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 }, + { "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 }, + { "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 }, + { "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 }, + { "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 }, + { "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 }, + { "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 }, + { "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 }, + { "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 }, + { "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 }, + { "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 }, + { "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 }, + { "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 }, + { "UNDERGROUND_PATH_WEST_EAST", 12, 2 }, + { "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 }, + { "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 }, + { "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 }, + { "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 }, +} diff --git a/src/save_convert/data/hidden_items_yellow.lua b/src/save_convert/data/hidden_items_yellow.lua new file mode 100644 index 00000000..8fc1874f --- /dev/null +++ b/src/save_convert/data/hidden_items_yellow.lua @@ -0,0 +1,31 @@ +return { + { "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 }, + { "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 }, + { "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 }, + { "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 }, + { "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 }, + { "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 }, + { "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 }, + { "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 }, + { "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 }, + { "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 }, + { "UNDERGROUND_PATH_WEST_EAST", 12, 2 }, + { "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 }, + { "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 }, + { "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 }, + { "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 }, + { "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 }, + { "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 }, + { "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 }, + { "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 }, + { "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 }, + { "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 }, + { "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 }, + { "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 }, + { "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 }, + { "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 }, + { "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 }, + { "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 }, + { "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 }, + { "POKEMON_MANSION_1F", 8, 16 }, +} diff --git a/src/script/Commands.lua b/src/script/Commands.lua index c424f16b..9390be2a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value) ctx.save[key] = value end +function Commands.load_player_starter_name(ctx) + local flags = ctx.save.flags or {} + local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU" + or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER" + or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE" + or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR" + or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species) + local def = species and ctx.game.data.pokemon[species] + ctx.game.stringBuffer = def and def.name or species or "" +end + +function Commands.spawn_pikachu_follower(ctx) + require("src.world.PikachuFollower").onMapEntered( + ctx.game, ctx.overworld, nil, false) +end + local function toggleObject(ctx, mapId, objName, visible) local save = ctx.save save.objectToggles = save.objectToggles or {} @@ -1131,9 +1147,7 @@ end -- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm -- re-points channel 1 at a stub that only changes the song's `tempo`) (#847). function Commands.play_music(ctx, songId, opts) - local tempo = opts and opts.tempo - require("src.core.Music").play(ctx.game.data, songId, nil, - tempo and { tempo = tempo } or nil) + require("src.core.Music").play(ctx.game.data, songId, nil, opts) if opts and opts.keep and ctx.overworld then ctx.overworld.keepMusicOnce = true end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 1a2c59ee..11226454 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) return end + if result == "flute_wake_pikachu" then + require("src.core.Sound").play(game.data, "Pokeflute") + showMessages(game, payload, function() + game.overworld.pikachuPewterSleepScene = nil + end) + 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 @@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) 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 + if ow and p and ow:facingIsShoreOrWater() then + ow:goFishing(id) + return end showMessages(game, { Strings("No good! It's not\neven near water.") }) return @@ -176,19 +181,25 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) + require("src.core.Sound").play(game.data, "Get_Item1") showMessages(game, { Strings("%s learned\n%s!", target.nickname or game.data.pokemon[target.species].name, mdef.name) }) if result == "learn" then consume(game, id) end + list.items = buildItems(game) + list.index = math.min(list.index, math.max(1, #list.items)) taught() else require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, function(learned) if learned and result == "learn" then consume(game, id) end + if learned then + list.items = buildItems(game) + list.index = math.min(list.index, math.max(1, #list.items)) + end if learned then taught() end end) end end - list:close() teach() return end @@ -322,6 +333,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) local mdef = game.data.moves[moveId] if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) + require("src.core.Sound").play(game.data, "Get_Item1") local name = target.nickname or def.name showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, nextStep) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 81a6357d..76b22781 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -119,6 +119,13 @@ local function deposit(game) onChoose = function(item, list) local mon = game.save.party[item.value] if not mon then return end + local Follower = require("src.world.PikachuFollower") + if Follower.isFollowingDisabled(game.overworld) + and Follower.isStarterPikachu(game.save, mon) then + game.stack:push(TextBox.new(game, t._SleepingPikachuText2 + or Strings("There isn't any\nresponse..."))) + return + end monSubmenu(game, "DEPOSIT", mon, function() if #game.save.party <= 1 then list.footer = Strings("You need at least\none POKéMON!") @@ -167,6 +174,16 @@ local function release(game) local mon = box[list.index] if not mon then return end local name = monName(game, mon) + if require("src.core.GameVersion").isYellow() + and mon.species == "PIKACHU" + and mon.otId == game.save.player.id + and mon.ot == game.save.player.name then + require("src.core.Sound").playCry(game.data, mon.species) + game.stack:push(TextBox.new(game, + (t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) + :gsub("{RAM:wNameBuffer}", name))) + return + end game.stack:push(TextBox.new(game, Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { defaultNo = true, noSound = true, diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index f32a60de..58c41b1d 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -17,12 +17,13 @@ local HM_MOVES = { CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, } -function MoveLearnMenu.new(game, mon, newMoveId, onDone) +function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound) local self = setmetatable({}, MoveLearnMenu) self.game = game self.mon = mon self.newMoveId = newMoveId self.onDone = onDone + self.learnedSound = learnedSound or "Get_Item1" self.index = 1 -- forget-list UI only after TryingToLearn YES (learn_move.asm .loop) self.selecting = false @@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned) self.selecting = false game.stack:pop() local msg + local opts if learned then -- pokered pages this as four texts in a row; _ForgotAndText carries -- the "And..." tail @@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned) "\f%s forgot\n%s!\fAnd...", name, self.forgot) .. "\f" .. romText(game.data, "_LearnedMove1Text", "%s learned\n%s!", name, mdef.name) + opts = { auto = { sound = function() + return require("src.core.Sound").play(game.data, self.learnedSound) + end, wait = true } } else msg = romText(game.data, "_DidNotLearnText", "%s\ndid not learn\v%s!", name, mdef.name) end game.stack:push(TextBox.new(game, msg, function() if self.onDone then self.onDone(learned) end - end)) + end, opts)) end function MoveLearnMenu:draw() diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 33df1d36..013b87fd 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -72,6 +72,21 @@ end local function sameItems(_, items) return items end +local function followerUnavailable(game, mon) + local ow = game.overworld + local Follower = require("src.world.PikachuFollower") + return Follower.isFollowingDisabled(ow) + and Follower.isStarterPikachu(game.save, mon) +end + +local function refuseUnavailable(self) + self.swapFrom = nil + local TextBox = require("src.render.TextBox") + local t = self.game.data and self.game.data.text or {} + self.game.stack:push(TextBox.new(self.game, + t._SleepingPikachuText1 or Strings("There isn't any\nresponse..."))) +end + -- 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, @@ -367,6 +382,10 @@ function PartyMenu:update(dt) self.submenu = nil elseif input:wasPressed("a") then local mon = party[self.index] + if followerUnavailable(self.game, mon) then + refuseUnavailable(self) + return + end local entry = self.subItems[self.subIndex] local action = entry.action if not action and entry.onSelect then @@ -577,6 +596,10 @@ function PartyMenu:update(dt) if self.onCancel then self.onCancel() end elseif input:wasPressed("a") and #party > 0 then local mon = party[self.index] + if followerUnavailable(self.game, mon) then + refuseUnavailable(self) + return + end if self.softboiledFrom then local user = party[self.softboiledFrom] local heal = math.floor(user.stats.hp / 5) diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index e51eeb17..b185f6ad 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -104,6 +104,27 @@ local function sell(game) dialogue = true, money = function() return game.save.money end, footer = greet, + 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") + local rebuilt = {} + for _, id in ipairs(order) do + local def = game.data.items[id] + rebuilt[#rebuilt + 1] = { + value = id, + label = def and def.name or id, + right = "x" .. game.save.inventory[id], + } + end + l.items = rebuilt + end, onChoose = function(item) local def = game.data.items[item.value] -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index b43ff715..6d6394be 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -34,7 +34,8 @@ function StartMenu.new(game) -- 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 = Strings("POKéMON"), onSelect = function() + table.insert(items, { label = Strings("POKéMON"), + keepOpen = #game.save.party == 0, onSelect = function() if #game.save.party == 0 then return end Screens.push(game, "PartyMenu", { onCancel = reopen }) end }) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index c83292c0..18b8e059 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -200,8 +200,9 @@ function TitleState.new(game, opts) self.versionFull = imagePath(title.versionRibbon) ~= nil self.version = tryImage(imagePath(title.versionRibbon or title.version) or "assets/generated/title/red_version.png") - self.player = tryImage(imagePath(title.player) - or "assets/generated/title/player.png") + self.playerPath = imagePath(title.player) + or "assets/generated/title/player.png" + self.player = tryImage(self.playerPath) -- ..(engine/movie/title2.asm ln 85) if self.player then local pw, ph = self.player:getDimensions() @@ -597,6 +598,12 @@ end -- ..(engine/movie/title.asm ln 28) function TitleState:draw() + local PaletteFX = require("src.render.PaletteFX") + local playerImage = self.player + if playerImage and PaletteFX.usesSpriteObp() then + playerImage = require("src.render.SpriteRenderer").obpImage( + self.playerPath, PaletteFX.ogObj()) + end love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) local scrollY = -(self.scy or 0) @@ -660,8 +667,8 @@ function TitleState:draw() -- layout has no cycling mon and no Red art (title_yellow.asm). if spriteTrueColor then local cover - if self.player then - local pw, ph = self.player:getDimensions() + if playerImage then + local pw, ph = playerImage:getDimensions() cover = { 82, 80, pw, ph } end markVisibleTrueColor(x, y, w, h, cover) @@ -670,11 +677,11 @@ function TitleState:draw() -- Red is OAM in the original: he draws over the mon's box edge if self.playerQuads then for _, part in ipairs(self.playerQuads) do - love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3]) + love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3]) end - love.graphics.draw(self.player, self.ballQuad, 82, self.ballY) - elseif self.player then - love.graphics.draw(self.player, 82, 80) + love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY) + elseif playerImage then + love.graphics.draw(playerImage, 82, 80) end end self:drawCopyright(136 + (preRibbon and 0 or scrollY)) diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index dff80f5d..b3c4f02c 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -272,9 +272,6 @@ function EvolutionAnim:commit() evolved.level, evolved) end --- One LearnMove call per move the new species picks up at this level. There --- is no forget screen in the Gold port yet, so a full moveset reports the way --- src/battle/gen2/Battle.lua's own level-up path does instead of prompting. function EvolutionAnim:nextLearn() self.learnIndex = (self.learnIndex or 0) + 1 local moveId = self.pending and self.pending[self.learnIndex] @@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn() self.learned[#self.learned + 1] = moveId self.lines = { self.nick .. " learned", moveName .. "!" } elseif reason == "full" then + if self.game and self.game.learnMoveOn then + self.phase = "waitingLearn" + return self.game:learnMoveOn(self.evolved, moveId, function(learned) + if learned then + self.learned[#self.learned + 1] = moveId + else + self.full[#self.full + 1] = moveId + end + self:nextLearn() + end) + end self.full[#self.full + 1] = moveId self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" } else diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 8adaf1f0..06d96da6 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -128,7 +128,8 @@ end function NPC:draw(camX, camY) local sprite, px, py, facing, phase, flip = self:pose() - sprite:draw(px, py, camX, camY, facing, phase, flip) + sprite:draw(px, py, camX, camY, facing, phase, flip, nil, nil, + self.frameOverride) end return NPC diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ce9f5a8d..bfd61826 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1087,6 +1087,7 @@ function OverworldState:update(dt) -- the player lands on desk Oak. local scripted = self.runner:isRunning() or #self.scriptMoves > 0 or self.engaging or self.emote or self.teleportOut + or self.flyAnim or self.flyArrive if not scripted and not self.transitioning then self:checkTrainerSight() -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and @@ -1095,6 +1096,7 @@ function OverworldState:update(dt) -- the player can never start another step after being spotted. scripted = self.runner:isRunning() or #self.scriptMoves > 0 or self.engaging or self.emote or self.teleportOut + or self.flyAnim or self.flyArrive end if not scripted and not self.transitioning then self:handleInput() @@ -1664,6 +1666,10 @@ end -- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group -- (no group means "Not even a nibble!"). function OverworldState:goFishing(rod) + if GameVersion.isYellow() then + Game.save.pikachuEmotionModifier = 2 + Game.save.pikachuMood = 0x81 + end local pool, always = fishingPool(Game.data, rod, self.map.id) local enc if Runtime.wantsHook("encounter.fishing") then @@ -2905,6 +2911,11 @@ end -- POKéMON" and "fighting fit". function OverworldState:nurseHeal(onDone, npc) local t = Game.data.text + if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then + Game.stack:push(TextBox.new(Game, + t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone)) + return + end local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!") local hello = t._PokemonCenterWelcomeText or Strings("Welcome to our\nPOKéMON CENTER!") @@ -2978,9 +2989,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc) end)) end if not npc then farewell() return end - npc.facing = "up" + npc.frameOverride = 3 -- bubble = false is the silent world hold, this port's DelayFrames - self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell } + self.emote = { npc = npc, frames = 20, bubble = false, onDone = function() + npc.frameOverride = nil + npc:facePlayer(self.player) + farewell() + end } end)) end @@ -2992,6 +3007,11 @@ end -- for the original serial handshake; declining prints "Please come again!" function OverworldState:cableClubReceptionist(onDone) local t = Game.data.text + if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then + Game.stack:push(TextBox.new(Game, + t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone)) + return + end local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!") if not Game.save.flags.EVENT_GOT_POKEDEX then -- CableClubNPC .didNotConnect path before the pokedex diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 9e59d45a..688c62f6 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy) return nil end +function PikachuFollower.isStarterPikachu(save, mon) + if not (mon and mon.species == "PIKACHU") then return false end + local player = save.player or {} + return mon.otId == player.id and mon.ot == player.name +end + +function PikachuFollower.isFollowingDisabled(ow) + return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene + or ow.pikachuPewterSleepScene) and true or false +end + -- ModifyPikachuHappiness. mon is the party mon the event applied to for -- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and -- WALKING instead require any healthy starter in the party @@ -199,6 +210,7 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad) -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil + ow.pikachuFanClubScene = nil remove(ow) if not shouldSpawn(game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: @@ -410,7 +422,8 @@ end -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) if ow.pikaHop then return end -- the counter hop owns the follower (#417) - if ow.pikachuBillsScene then return end + if ow.pikachuBillsScene or ow.pikachuFanClubScene + or ow.pikachuPewterSleepScene then return end local npc = findFollower(ow) if not npc then if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end @@ -713,6 +726,13 @@ function PikachuFollower.talk(game, ow, npc, done) ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing local save = game.save local emotion = selectEmotion(game, ow, save) + if ow.pikachuPewterSleepScene then + local finish = done + done = function() + ow.pikachuPewterSleepScene = nil + if finish then finish() end + end + end local e = EMOTIONS[emotion] or EMOTIONS[1] if e.turnAway then npc.facing = ow.player.facing -- pikaemotion_9: back to the player @@ -798,6 +818,28 @@ local function movePikachu(ow, npc, steps, onDone) nextStep(1) end +function PikachuFollower.onFanClubEntered(game, ow) + if not (GameVersion.isYellow() and ow.map + and ow.map.id == "POKEMON_FAN_CLUB") then return end + local starter = PikachuFollower.starterInParty(game.save) + local npc = findFollower(ow) + if not npc or (starter and starter.status) then return end + ow.pikachuFanClubScene = true + ow.pikachuMapScriptActive = true + ow.player.facing = "down" + for _, other in ipairs(ow.npcs or {}) do + if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then + other.movementStatus = 2 + other.facing = "down" + break + end + end + billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") + movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function() + npc.facing = "up" + end) +end + function PikachuFollower.onBillsHouseEnter(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then return diff --git a/tests/engine/gen2_sun_stone_bug1219.lua b/tests/engine/gen2_sun_stone_bug1219.lua new file mode 100644 index 00000000..f5167a28 --- /dev/null +++ b/tests/engine/gen2_sun_stone_bug1219.lua @@ -0,0 +1,82 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local ItemEffects = require("src.core.gen2.ItemEffects") +local Game2 = require("src.core.Game2") +local Screens = require("src.ui.Screens") +local EvolutionAnim = require("src.ui.gen2.EvolutionAnim") + +local data = { + pokemon = { + SUNKERN = { + evolutions = { + { method = "EVOLVE_ITEM", item = "SUN_STONE", into = "SUNFLORA" }, + }, + }, + }, +} +local mon = { species = "SUNKERN", item = nil } + +T.eq(ItemEffects.partyAction("SUN_STONE"), "stone", + "SUN STONE opens the party target flow") +local result = ItemEffects.useOnMon("SUN_STONE", mon, data) +T.check(result.used, "SUN STONE succeeds on SUNKERN") +T.eq(result.evolution and result.evolution.into, "SUNFLORA", + "SUN STONE selects SUNFLORA") + +mon.item = "EVERSTONE" +result = ItemEffects.useOnMon("SUN_STONE", mon, data) +T.check(not result.used, "held EVERSTONE refuses the evolution") + +local function startStone() + local partyOptions, evolutionOptions + local game = setmetatable({ + data = { + pokemon = data.pokemon, + screens = { + Gen2PartyMenu = function(_, options) + partyOptions = options + return {} + end, + Gen2EvolutionAnim = function(_, options) + evolutionOptions = options + return {} + end, + }, + }, + save = { + party = { { species = "SUNKERN", item = nil } }, + inventory = { SUN_STONE = 1 }, + }, + stack = { pop = function() end, push = function() end }, + }, Game2) + Screens.invalidate() + game:usePartyItem("SUN_STONE") + partyOptions.onChoose(nil, game.save.party[1]) + return game, evolutionOptions +end + +local game, evolution = startStone() +T.check(evolution.force, "SUN STONE evolution sets wForceEvolution") +T.eq(game.save.inventory.SUN_STONE, 1, + "SUN STONE remains until evolution succeeds") + +local animation = EvolutionAnim.new({ data = { pokemon = data.pokemon } }, { + mon = game.save.party[1], entry = evolution and evolution.entry, + force = evolution and evolution.force, +}) +T.check(not animation:cancelPressed({ wasPressed = function(_, key) + return key == "b" +end }), "B cannot cancel a forced stone evolution") + +evolution.onDone({ canceled = true }) +T.eq(game.save.inventory.SUN_STONE, 1, + "a canceled evolution does not consume SUN STONE") + +game, evolution = startStone() +evolution.onDone({ evolved = { species = "SUNFLORA" } }) +T.eq(game.save.inventory.SUN_STONE, nil, + "a completed evolution consumes SUN STONE") +Screens.invalidate() + +T.finish("gen2 sun stone bug 1219") diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index ff46a1e4..d60870fc 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -56,10 +56,24 @@ do local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } })) check(m.aaa.enabled, "a mod with no options entry defaults to enabled") check(not m.bbb.enabled, "an explicit false disables the mod") + check(m.aaa.enabledByVersion.red and m.aaa.enabledByVersion.gold, + "every row exposes its enabled answer for each game") eq(m.aaa.status, "ok", "a healthy enabled mod is ok") eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") end +do + local manifests = { + mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }), + } + local row = byId(LauncherMods.deriveList(manifests, { + mods = { one = true }, modsByVersion = { gold = { one = false } }, + }, "gold")).one + check(row.enabledByVersion.red, "the Red checkbox keeps the shared answer") + check(not row.enabledByVersion.gold, "the Gold checkbox reads Gold's answer") + check(not row.enabled, "the selected game's row state matches its checkbox") +end + -- ------- experimental defaults to disabled; github surfaces on the row do @@ -291,10 +305,9 @@ end -- ------- enable flags: the panel reads exactly what the switch writes -- --- One scope for both halves (SaveData.modScope). While per-game flags are a --- preview the shared flag is the whole answer, so a modsByVersion overlay -- --- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can --- never leave the switch showing an answer no writer can reach. +-- One scope for both halves (SaveData.modScope). Per-game flags are live, so +-- a modsByVersion answer -- including one restored from a .g1rmodlist -- is +-- both what the launcher shows and what the next boot reads. local SaveData = require("src.core.SaveData") @@ -309,9 +322,8 @@ do } local planted = { mods = { one = true }, modsByVersion = { gold = { one = false } } } - local expected = SaveData.PER_VERSION_MODS and false or true eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, - expected, "the overlay is read exactly when a write can reach it") + false, "the overlay is read exactly when a write can reach it") -- the round trip, the thing the dead switch failed: flip it, re-derive local options = { mods = {} } diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index f77390e7..e781818b 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -162,6 +162,32 @@ do "modScope follows the PER_VERSION_MODS switch") end +do + -- First launch after per-game controls shipped: preserve every old answer + -- and make the old implicit defaults explicit for the installed set. + local opts = { + mods = { old_on = true, old_off = false }, + modsByVersion = { gold = { old_on = false } }, + } + check(SaveData.migrateModEnablement(opts, { + { id = "old_on" }, { id = "old_off" }, { id = "implicit" }, + { id = "lab", experimental = true }, + }), "legacy mod state is migrated once") + check(opts.modsByVersionMigrated, "the migration is marked complete") + eq(SaveData.modEnabled(opts, "old_on", "red"), true, + "an old enabled mod is enabled for Red") + eq(SaveData.modEnabled(opts, "old_on", "gold"), false, + "an already-stored preview answer is preserved") + eq(SaveData.modEnabled(opts, "old_off", "blue"), false, + "an old disabled mod stays disabled for every game") + eq(SaveData.modEnabled(opts, "implicit", "yellow"), true, + "an old implicit default is enabled for every game") + eq(SaveData.modEnabled(opts, "lab", "red"), false, + "an experimental mod keeps its existing opt-in default") + check(not SaveData.migrateModEnablement(opts, { { id = "newer" } }), + "a later mod install does not rerun the legacy migration") +end + -- ------- a profile carries the per-game half of a setup do diff --git a/tests/engine/nurse_bow_bug995.lua b/tests/engine/nurse_bow_bug995.lua index d34c706e..f4cd69be 100644 --- a/tests/engine/nurse_bow_bug995.lua +++ b/tests/engine/nurse_bow_bug995.lua @@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil, pushed[1].onDone() T.eq(#pushed, 1, "the farewell waits for the bow") -T.eq(nurse.facing, "up", "image index $14: the nurse bows") +T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows") T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause") local hold = fakeSelf.emote or {} T.eq(hold.npc, nurse, "the hold is anchored on the nurse") @@ -79,11 +79,12 @@ if hold.onDone then hold.onDone() end T.eq(#pushed, 2, "the farewell follows the bow") local farewell = pushed[2] or {} T.eq(farewell.text, BYE, "second box is the farewell text") -T.eq(nurse.facing, "up", "she is still bowed while the farewell prints") +T.eq(nurse.frameOverride, nil, "the bow ends before the farewell prints") +T.eq(nurse.facing, "down", "the nurse faces the player for the farewell") if farewell.onDone then farewell.onDone() end -T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back") -T.eq(faced, 1, "she is turned back exactly once") +T.eq(nurse.facing, "down", "the trailing UpdateSprites keeps her facing") +T.eq(faced, 2, "she is turned back before and after the farewell") T.eq(finished, 1, "control returns to the player once, after the farewell") -- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text diff --git a/tests/engine/open_menu_bugs_949_1149.lua b/tests/engine/open_menu_bugs_949_1149.lua new file mode 100644 index 00000000..c9af0053 --- /dev/null +++ b/tests/engine/open_menu_bugs_949_1149.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local GenSave = require("src.save_convert.GenSave") +local romText = require("src.core.RomText") + +local localized = { + text = { + _MoveIsDisabledText = "The move {RAM:wNameBuffer} from {USER} is disabled!", + }, +} +T.eq(romText(localized, "_MoveIsDisabledText", "%s's %s is disabled!", { + USER = "PIKACHU", ["RAM:wNameBuffer"] = "THUNDER", +}), "The move THUNDER from PIKACHU is disabled!", + "named ROM tokens survive translation reordering") + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local data = { + pokemon = {}, moves = {}, items = {}, maps = {}, + hiddenItems = loadfile("src/save_convert/data/hidden_items.lua")(), +} +local save = { + player = { name = "RED", id = 1, map = "PALLET_TOWN", x = 0, y = 0 }, + rival = { name = "BLUE" }, party = {}, boxes = {}, inventory = {}, + pcItems = {}, flags = {}, pokedex = { seen = {}, owned = {} }, + hiddenTaken = { + VIRIDIAN_FOREST_1_18 = true, + VIRIDIAN_CITY_14_4 = true, + }, +} +for i = 1, 12 do save.boxes[i] = {} end +local bytes = GenSave.encode(save, data, nil) +local decoded = GenSave.decode(bytes, data) +T.check(decoded.hiddenTaken.VIRIDIAN_FOREST_1_18, + "Viridian Forest hidden potion imports from wObtainedHiddenItemsFlags") +T.check(decoded.hiddenTaken.VIRIDIAN_CITY_14_4, + "Viridian City hidden potion imports from wObtainedHiddenItemsFlags") +T.check(not decoded.hiddenTaken.ROUTE_9_14_7, + "an uncollected hidden item remains available") + +love = love or require("tests.love_stub") +local start = require("src.ui.StartMenu").new({ + data = {}, save = { + flags = {}, party = {}, inventory = {}, options = {}, + player = { name = "RED" }, pokedex = { owned = {} }, + }, +}) +local pokemonRow +for _, row in ipairs(start.items) do + if row.label == "POKéMON" then pokemonRow = row break end +end +T.check(pokemonRow and pokemonRow.keepOpen, + "the empty-party POKéMON row leaves the start menu open") + +T.finish("open menu bugs 949 and 1149") diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 719a9acd..790de5a6 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -58,6 +58,8 @@ local MATCHUPS = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, + -- Sonic Boom is Normal; Gen 2 StaticDamage still respects Ghost immunity. + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, } local MOVES = { @@ -144,6 +146,15 @@ local POKEMON = { levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, + GASTLY = { + id = "GASTLY", index = 92, name = "GASTLY", + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + types = { "GHOST", "POISON" }, catchRate = 190, baseExp = 62, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "LICK" } }, + evolutions = {}, + }, } local DATA = { @@ -613,6 +624,21 @@ local battle, player, wild = newBattle() check("battle picks the first healthy mon", battle.player, player) check("wild battle flag", battle.wild, true) +-- Gen 2 battles expose BattleRandom (`random` / :roller()) and the Gen 1 / +-- love.math `rng` over the same stream. +check("battle.rng is present", type(battle.rng), "function") +check("battle.rng(lo,hi) respects bounds", battle.rng(10, 10), 10) +check("battle.rng(lo,hi) another fixed point", battle.rng(50, 50), 50) +do + local roll = battle.rng(0, 255) + check("battle.rng(0,255) is an integer", roll == math.floor(roll), true) + check("battle.rng(0,255) in range", roll >= 0 and roll <= 255, true) +end +-- zeroRandom always returns 0, so the love adapter maps: +-- rng(n) → 0+1 = 1; rng(lo,hi) → lo + 0 = lo +check("battle.rng(n) is 1..n over BattleRandom", battle.rng(7), 1) +check("battle.rng(0,99) uses both args", battle.rng(0, 99), 0) + -- A move spends PP and deals damage. local before = wild.hp battle:takeTurn({ kind = "move", move = "TACKLE" }) @@ -910,6 +936,16 @@ local EFFECT_MOVES = { SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1, type = "FIGHTING", accuracy = 100, pp = 20, effect = "EFFECT_LEVEL_DAMAGE" }, + -- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40). + SONICBOOM = { id = "SONICBOOM", name = "SONICBOOM", power = 20, + type = "NORMAL", accuracy = 90, pp = 20, + effect = "EFFECT_STATIC_DAMAGE" }, + DRAGON_RAGE = { id = "DRAGON_RAGE", name = "DRAGON RAGE", power = 40, + type = "DRAGON", accuracy = 100, pp = 10, + effect = "EFFECT_STATIC_DAMAGE" }, + MAGNITUDE = { id = "MAGNITUDE", name = "MAGNITUDE", power = 1, + type = "GROUND", accuracy = 100, pp = 30, + effect = "EFFECT_MAGNITUDE" }, LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL", accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" }, -- data/moves/moves.asm:169, :172, :151. @@ -1078,6 +1114,83 @@ tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" }) check("seismic toss deals the level", tossBefore - tossWild.hp, tossPlayer.level) +-- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40). +-- Cart: constantdamage + resettypematchup (effects.asm StaticDamage). +do + check("static damage uses move power", + Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, + 40), 40) + check("sonic boom fixed damage is 20", + Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, + 20), 20) + + local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" }) + boomWild.hp = 100 + boomWild.maxHp = 100 + local boomBefore = boomWild.hp + boomBattle:takeTurn({ kind = "move", move = "SONICBOOM" }) + check("sonic boom deals flat 20", boomBefore - boomWild.hp, 20) + + -- Normal vs Ghost is 0x; StaticDamage's resettypematchup misses. + local ghostBattle, _, ghostWild = effectBattle({ "SONICBOOM" }) + ghostWild.species = "GASTLY" + local ghostBefore = ghostWild.hp + ghostBattle:takeTurn({ kind = "move", move = "SONICBOOM" }) + check("sonic boom misses Ghost in Gen 2", ghostWild.hp, ghostBefore) + + local rageBattle, _, rageWild = effectBattle({ "DRAGON_RAGE" }) + rageWild.hp = 100 + rageWild.maxHp = 100 + local rageBefore = rageWild.hp + rageBattle:takeTurn({ kind = "move", move = "DRAGON_RAGE" }) + check("dragon rage deals flat 40", rageBefore - rageWild.hp, 40) +end + +-- Magnitude: BattleRandom walks magnitude_power.asm. Nil random must not +-- collapse to roll 0 (always Magnitude 4); Battle.new always supplies a roller. +do + local p4, n4 = Effects.magnitudePower(function() return 0 end) + check("magnitude roll 0 is power 10", p4, 10) + check("magnitude roll 0 is number 4", n4, 4) + + local p8, n8 = Effects.magnitudePower(function() return 200 end) + check("magnitude roll 200 is power 90", p8, 90) + check("magnitude roll 200 is number 8", n8, 8) + + local seen = {} + for roll = 0, 255 do + local _, number = Effects.magnitudePower(function() return roll end) + seen[number] = true + end + for want = 4, 10 do + check(("magnitude table reaches %d"):format(want), seen[want] == true, true) + end + + -- Engine path: inject a mid-table roll and confirm the announce + damage. + local magBattle, magPlayer, magWild = newBattle({ + random = function(n) + -- Pin the getmagnitude byte: return 200 whenever n == 256. + if n == 256 then return 200 end + return 0 + end, + }) + magPlayer.moves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } } + magWild.hp = 200 + magWild.maxHp = 200 + local magEvents = magBattle:takeTurn({ kind = "move", move = "MAGNITUDE" }) + local announced + for _, ev in ipairs(magEvents) do + if ev.kind == "message" and type(ev.text) == "string" + and ev.text:match("^Magnitude %d+") then + announced = ev.text + break + end + end + check("magnitude announces rolled number", announced, "Magnitude 8!") + check("magnitude deals more than power-1 would", + magWild.hp < 200, true) +end + -- --------------------------------------------------------------- held items local Ai = require("src.battle.gen2.Ai") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 86be2093..5867b59d 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -1017,8 +1017,10 @@ press(ms, "select") check(avail[1].enabled == false, "SELECT quick-toggles the focused mod") check(ms:isStaged(avail[1]), "a flip against boot state is staged") check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph") -check(mgame.save.options.mods.badmod == false, - "the live options table mirrors the flip") +local managerScope = ms:enableScope() +check(managerScope and mgame.save.options.modsByVersion + and mgame.save.options.modsByVersion[managerScope].badmod == false, + "the live options table mirrors the flip for this game") check(ms.restartPending, "staged changes arm the apply screen") ms:discardChanges() check(avail[1].enabled == true and not ms.restartPending, diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 4c5876c0..8f530ee3 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -60,14 +60,14 @@ for _, r in ipairs(rows) do end check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame") --- The post-battle walk takes the right-hand detour before heading north, so +-- The post-battle walk takes the left-hand detour before heading north, so -- the player does not visibly pass through the rival at (4,2). local route = {} for _, r in ipairs(rows) do if r[1] == "move_player" then route[#route + 1] = r end end -eq(route[#route - 1] and route[#route - 1][2], "right", - "walk-out route first moves right around the rival") +eq(route[#route - 1] and route[#route - 1][2], "left", + "walk-out route first moves left around the rival") eq(route[#route] and route[#route][2], "up", "walk-out route then heads north to Hall of Fame") diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index 4cb22bed..9b7e5dea 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -203,13 +203,13 @@ do eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes") end --- the OPTIONS animation toggle still gates the whole thing; the sound does not +-- engine/battle/animations.asm PlayApplyingAttackAnimation do local tb = freshBattle() Game.save.options.animations = false tb:applyHitFx({ animType = 5, sfx = "Damage" }) - eq(tb.fx.shakeProg, nil, "animations off arms no shake") - eq(tb.fx.blink, nil, "and no blink") + check(tb.fx.shakeProg ~= nil, "animations off keeps the hit shake") + eq(tb.fx.blink, nil, "type 5 remains a shake, not a blink") Game.save.options.animations = true end diff --git a/tests/parity_yellow_disabled_pikachu.lua b/tests/parity_yellow_disabled_pikachu.lua new file mode 100644 index 00000000..86625442 --- /dev/null +++ b/tests/parity_yellow_disabled_pikachu.lua @@ -0,0 +1,93 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("parity Yellow disabled Pikachu") +local check, eq = S.check, S.eq +local Data = require("src.core.Data") +Data:load() +local GameVersion = require("src.core.GameVersion") +local Follower = require("src.world.PikachuFollower") +local ItemEffects = require("src.inventory.ItemEffects") +local PartyMenu = require("src.ui.PartyMenu") +local BoxMenu = require("src.ui.BoxMenu") +local TextBox = require("src.render.TextBox") + +local oldVersion = GameVersion.get() +GameVersion.set("yellow") + +local save = { + player = { id = 7, name = "RED" }, + party = { + { species = "PIKACHU", otId = 7, ot = "RED", hp = 20 }, + { species = "PIDGEY", otId = 7, ot = "RED", hp = 20 }, + }, +} + +check(Follower.isStarterPikachu(save, save.party[1]), "the player Pikachu is identified") +check(not Follower.isStarterPikachu(save, save.party[2]), "other party members are not starter Pikachu") + +local pika = { pikachuFollower = true, cellX = 3, cellY = 4, facing = "down" } +local seel = { def = { name = "POKEMONFANCLUB_SEEL" }, cellX = 1, cellY = 4 } +local moves = {} +local ow = { + map = { id = "POKEMON_FAN_CLUB" }, + player = { cellX = 3, cellY = 5, facing = "up" }, + npcs = { pika, seel }, + scriptMove = function(_, npc, dir, tiles, done) + moves[#moves + 1] = { dir, tiles } + npc.facing = dir + if done then done() end + end, +} + +Follower.onFanClubEntered({ save = save, data = Data }, ow) +check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following") +check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag") +eq(ow.player.facing, "down", "Fan Club resets the player direction") +eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement") +eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile") +eq(moves[2] and moves[2][1], "right", "Fan Club then walks right") +eq(moves[2] and moves[2][2], 3, "Fan Club walks right three tiles") +eq(moves[3] and moves[3][1], "up", "Fan Club ends walking up") +eq(moves[3] and moves[3][2], 1, "Fan Club final up spans one tile") +eq(seel.movementStatus, 2, "Fan Club puts Seel into movement delay") +eq(seel.facing, "down", "Fan Club turns Seel down") +check(Follower.isFollowingDisabled(ow), "disabled Fan Club follower blocks starter selection") + +local sleepOw = { + map = { id = "PEWTER_POKECENTER" }, + player = { cellX = 3, cellY = 5 }, + npcs = { pika }, + pikachuPewterSleepScene = true, +} +local result = ItemEffects.use(Data, save, "POKE_FLUTE", nil, nil, nil, sleepOw) +eq(result, "flute_wake_pikachu", "Poké Flute is allowed next to sleeping Pikachu") +check(Follower.isFollowingDisabled(sleepOw), "sleeping Pikachu disables normal follower actions") + +local pushed = {} +local partyGame = { + save = save, + overworld = sleepOw, + stack = { push = function(_, state) pushed[#pushed + 1] = state end }, + input = { wasPressed = function(_, key) return key == "a" end }, +} +PartyMenu.new(partyGame):update() +check(getmetatable(pushed[#pushed]) == TextBox, + "sleeping Pikachu cannot be selected from the party menu") + +local boxGame = { + save = save, + overworld = sleepOw, + data = { pokemon = { PIKACHU = { name = "PIKACHU" }, PIDGEY = { name = "PIDGEY" } }, text = {} }, + stack = { push = function(_, state) pushed[#pushed + 1] = state end }, +} +local pc = BoxMenu.new(boxGame) +pc.items[2].onSelect() +local depositList = pushed[#pushed] +depositList.onChoose(depositList.items[1], depositList) +check(getmetatable(pushed[#pushed]) == TextBox, + "sleeping Pikachu cannot be deposited into Bill's PC") +eq(#save.party, 2, "PC refusal leaves the party unchanged") + +GameVersion.set(oldVersion) +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 21c4790f..d7122562 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -891,8 +891,7 @@ do 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 + -- engine/battle/core.asm ApplyDamageToEnemyPokemon do Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } local rb = BattleState.newWild(Game, "RATTATA", 3) @@ -903,8 +902,8 @@ do 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") + eq(hpBefore - rb.player.mon.hp, 1, + "recoil is capped damage / 4 with a minimum of 1") local db = BattleState.newWild(Game, "RATTATA", 3) db.enemy.mon.hp = 1 @@ -914,9 +913,9 @@ do 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), + eq(db.player.mon.hp - 1, 1, + "drain heals capped damage / 2 with a minimum of 1") + eq(db.lastDamage, 1, "drain halves wDamage in place (Counter would see the half)") end