Compare commits

..

10 Commits

Author SHA1 Message Date
bryanthaboi 8e6b5ae73d Merge pull request #1234 from bryanthaboi/dev
bugs and slugs
2026-08-13 15:44:26 -04:00
bryanthaboi 0b96ac0ab6 fancy cart 2026-08-13 15:36:08 -04:00
bryanthaboi f6a035947f Update OverworldController.lua 2026-08-13 13:31:36 -04:00
bryanthaboi 941181d31c check bonxes 2026-08-13 13:22:18 -04:00
bryanthaboi 9ceb1a8940 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 13:07:22 -04:00
bryanthaboi 37051a26b5 CLOSES #1206, CLOSES #1189, CLOSES #1175, CLOSES #1129, CLOSES #1119, CLOSES #1089, CLOSES #1073, CLOSES #1069, CLOSES #1065, CLOSES #1010, CLOSES #990, CLOSES #989, CLOSES #988, CLOSES #978, CLOSES #944, CLOSES #936, CLOSES #1221, CLOSES #1220, CLOSES #1193, CLOSES #1101, CLOSES #1066, CLOSES #1056, CLOSES #1041, CLOSES #984, CLOSES #1225, CLOSES #1214, CLOSES #1011, CLOSES #1035, CLOSES #1219, CLOSES #1048, CLOSES #1047, CLOSES #964, CLOSES #949, CLOSES #1149, CLOSES #1007, CLOSES #914, CLOSES #1115, CLOSES #1146, CLOSES #999, CLOSES #1207, CLOSES #1029, CLOSES #1120, CLOSES #987, CLOSES #983 2026-08-13 13:07:20 -04:00
bryanthaboi b12a0dac91 Merge pull request #1205 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-13 11:27:02 -04:00
1jamie a722d24759 fix merge conflicts with dev 2026-08-13 07:39:11 -05:00
github-actions 833388c235 chore(ios): update app-repo.json [skip ci] 2026-08-13 06:22:43 -04:00
1jamie 6ff7dd0c38 refactor(battle): enhance RNG handling and fixed damage mechanics
- Always supply Gen 2 battle random (BattleRandom) and Gen 1 / love-style rng.
- Add EFFECT_STATIC_DAMAGE for Sonic Boom and Dragon Rage (move power; Ghost
  immunity via resettypematchup for static damage only).
- Keep Magnitude rolling via BattleRandom instead of collapsing to Magnitude 4.
- Cover RNG, static damage, and Magnitude in gen2 battle tests.
2026-08-12 17:52:02 -05:00
64 changed files with 1437 additions and 250 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+13 -13
View File
@@ -246,9 +246,17 @@ return {
-- the table sprites; re-entering the lab applies the same HideObject -- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex). -- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow) onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then local flags = game.save.flags or {}
return 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 end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -286,6 +294,7 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port -- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596). -- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = { local rows = {
{ "face_player_dir", "up" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1 { "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
@@ -309,28 +318,19 @@ return {
local base = #rows local base = #rows
local party = flags.EVENT_CHOSE_BULBASAUR and 3 local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1 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 }) table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" }) table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) 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<PLAYER>! 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, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the -- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and -- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596). -- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" }) 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, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" }) table.insert(rows, { "play_music", "Music_OaksLab" })
+10 -3
View File
@@ -226,9 +226,15 @@ return {
}, },
onEnter = function(game, ow) onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then local flags = game.save.flags or {}
return 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 end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -296,13 +302,14 @@ return {
table.insert(rows, { "wait", 20 }) table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" }) 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, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" }) table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009) -- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
table.insert(rows, { "face_player_dir", "up" }) table.insert(rows, { "face_player_dir", "up" })
table.insert(rows, { "set_field", "pikachuInBall", false }) table.insert(rows, { "set_field", "pikachuInBall", false })
table.insert(rows, { "spawn_pikachu_follower" })
table.insert(rows, { "play_cry", "PIKACHU" }) table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
+19 -19
View File
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
M.BLUES_HOUSE = { M.BLUES_HOUSE = {
talk = { talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = { TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2 { "check_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump_if_true", 10 }, -- 3 { "jump_if_true", "got_map" },
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4 { "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_false", 12 }, -- 5 { "jump_if_false", "too_early" },
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6 { "show_text", "_BluesHouseDaisyOfferMapText" },
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the -- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText) -- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7 { "give_item", "TOWN_MAP", 1, "_GotMapText" },
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8 { "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
{ "jump", 13 }, -- 9 { "set_flag", "EVENT_GOT_TOWN_MAP" },
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10 { "jump", "end" },
{ "jump", 13 }, -- 11 { "label", "got_map" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12 { "show_text", "_BluesHouseDaisyUseMapText" },
{ "jump", "end" },
{ "label", "too_early" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
}, },
}, },
} }
@@ -1058,6 +1061,7 @@ local championsRoomRivalScript = {
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down -- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 17 { "face_object", 1, "left" }, -- 17
{ "face_object", 2, "down" }, -- 18 { "face_object", 2, "down" }, -- 18
{ "load_player_starter_name" },
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19 { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
-- OakDisappointedWithRivalScript: Oak turns to the rival (right) -- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 20 { "face_object", 2, "right" }, -- 20
@@ -1067,13 +1071,8 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23 { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25 { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement. -- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
-- The player walks out after Oak instead of the screen just fading on the { "move_player", "left", 1 },
-- 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
{ "move_player", "up", 3 }, -- 27 { "move_player", "up", 3 }, -- 27
-- hand the induction off to the HALL_OF_FAME room (consumed by its -- 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) -- 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 { "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9 { "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11 { "jump", "end" }, -- 11
+3
View File
@@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
M.POKEMON_FAN_CLUB = { M.POKEMON_FAN_CLUB = {
onEnter = function(game, ow)
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
end,
talk = { talk = {
TEXT_POKEMONFANCLUB_CHAIRMAN = { TEXT_POKEMONFANCLUB_CHAIRMAN = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
+18 -13
View File
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5) "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)
end, end,
}, },
@@ -512,24 +512,29 @@ M.ROUTE_24 = {
local flags = game.save.flags local flags = game.save.flags
local function battleOrDone() local function battleOrDone()
if ow:trainerDefeated(npc) then if ow:trainerDefeated(npc) then
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done) push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else else
ow:engageTrainer(npc, done) ow:engageTrainer(npc, done)
end end
end end
if not flags.EVENT_GOT_NUGGET then if not flags.EVENT_GOT_NUGGET then
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f" local t = text(game)
.. "You just earned a\nfabulous prize!", function() 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 flags.EVENT_GOT_NUGGET = true
require("src.inventory.Bag").add(game.save, "NUGGET", 1) game.stringBuffer = game.data.items.NUGGET.name
push(game, ("%s received\na NUGGET!"):format(game.save.player.name), push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
function() require("src.core.Sound").play(game.data, "Get_Item1")
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?", push(game, t._Route24CooltrainerM1JoinTeamRocketText,
function() battleOrDone)
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n" end)
.. "you my power!", battleOrDone)
end)
end)
end) end)
return return
end end
+22 -10
View File
@@ -508,12 +508,12 @@ M.PEWTER_CITY = {
-- Rival ambush: show the hidden rival, walk him up to the player, run -- 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 -- the battle rows, march him back and hide him. On a loss the walk is
-- skipped (the blackout rebuilds the map mid-script). -- 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 if ow.runner:isRunning() then return false end
ow.player.facing = playerFacing ow.player.facing = playerFacing
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music -- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
-- takes over and the map theme returns after the victory jingle -- 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) ow.runner:run(rows)
return true return true
end end
@@ -567,10 +567,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "face_object", objIndex, rivalFacing }, -- 3 { "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5 { "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 12 }, -- 6
{ "set_flag", beatFlag }, -- 7 { "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 { "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 { "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11 { "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 if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow, return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10, 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 end
return false return false
end, end,
@@ -618,10 +621,11 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3 { "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 { "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 -- 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 -- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing. -- 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") local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry -- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it -- 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 if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return return
end 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,
} }
end end
@@ -789,7 +799,7 @@ M.PEWTER_POKECENTER = {
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!", text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) })) done, { auto = jigglypuffDance(game, npc, ow) }))
end, end,
}, },
} }
@@ -864,10 +874,11 @@ M.SILPH_CO_7F = {
{ "face_object", 9, "up" }, -- 4 { "face_object", 9, "up" }, -- 4
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5 { "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6 { "rival_battle", "OPP_RIVAL2", 7 }, -- 6
{ "jump_if_false", 12 }, -- 7 { "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8 { "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9 { "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10 { "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "move_npc_to", 9, 5, y + 1 }, -- 11 { "move_npc_to", 9, 5, y + 1 }, -- 11
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12 { "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
}, "down") }, "down")
@@ -899,10 +910,11 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3 { "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4 { "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5 { "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10 { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11 { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left") }, onLeft and "up" or "left")
+8 -6
View File
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
duplicate) and returns one row per mod: duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`. `{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then `badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means `"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
enabled, matching the loader's own default). 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 - `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle` `LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own. `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 - `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
boolean, the exact shape `Loader:_saveState` writes, so the running game answer, so the running game and the in-game `ManagerState` see the change on
and the in-game `ManagerState` see the change on next boot. The mods panel next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
calls this on every toggle and re-derives the list right away and Gold on every row and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict) (`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload. shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with - `LauncherMods.installZip(path)` mounts the archive with
+8 -8
View File
@@ -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 <id> (not for Gold)`, dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
matching the loader's contagious skip. matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`, enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
so `SaveData.modScope` answers nil for every caller and the launcher panel, the for every installed mod, and the loader and in-game manager read the same
in-game manager and the loader all read *and* write the one shared game-specific answer on the next boot. On the first launch after this feature,
`options.mods` flag. Nothing consults the overlay for enablement until that the existing shared state is copied to every game, so a mod that was enabled
flips, which is deliberate: the overlay is plantable from an imported remains enabled everywhere; after that, changing one checkbox affects only
`.g1rmodlist`, and a reader scoped differently from the writers would show a that game. New mods still default to enabled on every game (experimental mods
mod set no boot would honour. retain their explicit opt-in default).
That is deliberate. Gold reimplements the battle engine, the overworld, the 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 script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
+7 -12
View File
@@ -260,18 +260,13 @@ selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### One limit worth knowing ### One limit worth knowing
**Per-game enable flags are still a preview.** The overlay **Enablement is per game.** The overlay
`options.modsByVersion[version][id]` exists and every surface goes through `options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled`, but `SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
`SaveData.PER_VERSION_MODS` is `false` manager, and loader. Existing shared settings are copied to every game the
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers first time this version sees the installed mods; from then on, each coloured
nil for every caller, so the launcher panel, the in-game manager *and* the game checkbox changes only that game's next boot. Nothing about this affects a
loader all read and write the one shared `options.mods` flag and the overlay is mod author; it affects what a player can express.
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.
Targeting is a different question from enablement and *is* enforced per game, Targeting is a different question from enablement and *is* enforced per game,
as above. The two do not share a switch. as above. The two do not share a switch.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.81",
"date": "2026-08-13",
"size": 9886854,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
},
{ {
"version": "0.1.80", "version": "0.1.80",
"date": "2026-08-12", "date": "2026-08-12",
+34 -13
View File
@@ -613,6 +613,7 @@ local function newBattle(game)
self.phase = "intro" self.phase = "intro"
self.menuIndex = 1 self.menuIndex = 1
self.moveIndex = 1 self.moveIndex = 1
self.playerMoveListIndex = 1
self.frame = 0 self.frame = 0
return self return self
end end
@@ -929,6 +930,11 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text }) table.insert(self.queue, self.nextInsert, { text = text })
end 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) -- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay) function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
@@ -1255,13 +1261,13 @@ function BattleState:updateQueue()
-- no subanimation player: keep the single-sound fallback (with -- no subanimation player: keep the single-sound fallback (with
-- the move's pitch/tempo modifiers; GROWL/ROAR play the -- the move's pitch/tempo modifiers; GROWL/ROAR play the
-- attacker's cry -- GetMoveSound/IsCryMove) -- 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 local attacker = item.attackerIsPlayer and self.player or self.enemy
if attacker then if attacker then
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species, require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
anim and anim.tempo) anim and anim.tempo)
end end
elseif anim and anim.sound then elseif self:animationsOn() and anim and anim.sound then
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
if Sound.playMove then if Sound.playMove then
Sound.playMove(self.data, anim) Sound.playMove(self.data, anim)
@@ -1361,6 +1367,11 @@ function BattleState:updateQueue()
self.current = nil self.current = nil
end end
elseif not (item and item.choice) then 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 -- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll -- (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 -- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1746,6 +1757,7 @@ end
local function sendOutMonCursors(self) local function sendOutMonCursors(self)
self.menuIndex = 1 self.menuIndex = 1
self.moveIndex = 1 self.moveIndex = 1
self.playerMoveListIndex = 1
end end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move -- 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 if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex) self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil self.moveSwapIndex = nil
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
else else
self.moveSwapIndex = self.moveIndex self.moveSwapIndex = self.moveIndex
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
end end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
@@ -2068,6 +2082,7 @@ function BattleState:update(dt)
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
else else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv) self:resolveTurn(mv)
end end
end end
@@ -2423,11 +2438,9 @@ function BattleState:resolveTurn(playerAction)
end end
local order local order
if pFirst then if pFirst then
order = { { self.player, self.enemy, playerAction }, order = { { true, playerAction }, { false, enemyAction } }
{ self.enemy, self.player, enemyAction } }
else else
order = { { self.enemy, self.player, enemyAction }, order = { { false, enemyAction }, { true, playerAction } }
{ self.player, self.enemy, playerAction } }
end end
self.phase = "messages" self.phase = "messages"
@@ -2435,7 +2448,9 @@ function BattleState:resolveTurn(playerAction)
for _, entry in ipairs(order) do for _, entry in ipairs(order) do
self:act(function() 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)
end end
self:act(function() self:endOfTurn() end) self:act(function() self:endOfTurn() end)
@@ -2945,7 +2960,7 @@ function BattleState:applyHitFx(hit)
Sound.play(self.data, hit.sfx) Sound.play(self.data, hit.sfx)
end end
end end
if not t or not self:animationsOn() then return end if not t then return end
if t == 1 then if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames -- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down -- then home for 3, b counting down
@@ -3839,9 +3854,12 @@ function BattleState:awardExp()
participants, alive = 1, { self.player.mon } participants, alive = 1, { self.player.mon }
end end
local function applyShare(mon, split, announce) 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, local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer", self.enemy.mon.level, self.kind == "trainer",
split, mon.traded) split, traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle -> -- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold; -- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213). -- 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!") local text = Strings.source("%s gained\n%d EXP. Points!")
if announce == "expAll" then if announce == "expAll" then
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!") 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!") text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
end end
self:sayNext(Strings(text, name, gained)) self:sayNext(Strings(text, name, gained))
@@ -4129,15 +4147,17 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) 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, self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name)) mdef.name), function()
return require("src.core.Sound").play(self.data, "Level_Up")
end)
return return
end end
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter; -- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
-- ordered insert so multi-level gains keep each level's checks -- ordered insert so multi-level gains keep each level's checks
-- between its own stat box and the next "grew to level" text -- between its own stat box and the next "grew to level" text
self:uiNext(function() self:uiNext(function()
return self:buildScreen("MoveLearnMenu", mon, moveId) return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
end) end)
end end
@@ -5791,6 +5811,7 @@ function BattleState:drawTextArea()
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8) Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
end end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end end
end end
+1 -2
View File
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.afterDamage then if record and record.afterDamage then
record.afterDamage(ctx, totalDealt) record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record local recoil = math.max(1, math.floor(totalDealt / 2))
local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user))) battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil) battle:applyDamage(user, recoil)
end end
+7 -8
View File
@@ -348,8 +348,9 @@ MoveEffects.primary = {
target.disabledTurns = battle.rng(1, 8) target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!" -- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target), return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
battle.data.moves[id].name) } { TARGET = displayName(target),
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
end, end,
SPLASH_EFFECT = function(battle) SPLASH_EFFECT = function(battle)
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
return dist[r + 1] return dist[r + 1]
end end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals -- engine/battle/core.asm ApplyDamageToEnemyPokemon
-- that amount, so Counter would see the halved value
local function drainHalf(label, text) local function drainHalf(label, text)
return function(ctx) 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 ctx.battle.lastDamage = heal
local mon = ctx.user.mon local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal) mon.hp = math.min(mon.stats.hp, mon.hp + heal)
@@ -529,9 +529,8 @@ MoveEffects.full = {
RECOIL_EFFECT = { RECOIL_EFFECT = {
afterDamage = function(ctx) afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually -- engine/battle/move_effects/recoil.asm
-- removed): overkill and substitute hits recoil at full strength local recoil = math.max(1, math.floor(ctx.totalDealt
local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4))) / (ctx.moveInst.struggle and 2 or 4)))
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user))) ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil) ctx.battle:applyDamage(ctx.user, recoil)
+3 -1
View File
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
or tostring(selectedMoveId) or tostring(selectedMoveId)
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText", 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 return false, msgs
end end
end end
+23 -8
View File
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon -- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon -- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
-- table directly, so that is what these payloads carry. -- 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 -- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
-- the cart's BattleRandom convention (0..n-1). Both keys are present. -- 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") local Runtime = require("src.mods.Runtime")
-- The two battle lines that carry the cart's own `line` break: a marker-bearing -- 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 -- 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 return math.random(n) - 1
end 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 -- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into -- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader -- 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` -- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
-- through Roamers.beginBattle and reads Battle.roaming back to -- through Roamers.beginBattle and reads Battle.roaming back to
-- bank the beast's HP afterwards -- 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) function Battle.new(opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, Battle) local self = setmetatable({}, Battle)
self.data = opts.data or {} self.data = opts.data or {}
self.random = opts.random or function(n) return rand(nil, n) end 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.party = opts.party or {}
self.trainer = opts.trainer self.trainer = opts.trainer
self.save = opts.save self.save = opts.save
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
Runtime.emit("battle.ended", { battle = self, result = outcome }) Runtime.emit("battle.ended", { battle = self, result = outcome })
end end
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the -- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
-- constructor (a headless test injects one, the game leaves it to love.math), -- `battle.rng` is the Gen 1 / love.math view of the same stream.
-- 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).
function Battle:roller() function Battle:roller()
if not self.rollerFn then if not self.rollerFn then
self.rollerFn = function(n) return rand(self.random, n) end self.rollerFn = function(n) return rand(self.random, n) end
+11 -1
View File
@@ -265,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores -- 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 -- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
-- power and the magnitude number the text prints. -- 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) 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 for _, row in ipairs(Effects.MAGNITUDE_POWER) do
if row[1] >= roll then return row[2], row[3] end if row[1] >= roll then return row[2], row[3] end
end end
+11 -2
View File
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
engine.tempo = header.tempo engine.tempo = header.tempo
engine.tempoLocked = true engine.tempoLocked = true
end end
for _, spec in ipairs(chip and chip.channels local channels = chip and chip.channels or headerChannels(banks, header)
or headerChannels(banks, header)) do 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 frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1 local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then if hardware == 4 then
+18 -2
View File
@@ -689,10 +689,26 @@ function Game2:usePartyItem(itemId)
self:say(result.text) self:say(result.text)
return return
end end
self:consumeItem(itemId) if action == "stone" then
if action == "candy" 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) self:say(result.text, function() self:afterRareCandy(mon, result) end)
else else
self:consumeItem(itemId)
self:say(result.text) self:say(result.text)
end end
end end
+17 -2
View File
@@ -64,6 +64,7 @@ state = {
fanfareResume = false, -- start/resume state.source when the fanfare ends fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut) fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current` tempo = nil, -- alternate-tempo override in force for `current`
start = nil,
failed = {}, -- labels whose def could not be started; logged once 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) song = selectSong(song, ctx)
local tempo = ctx and ctx.tempo or nil 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 -- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against -- 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) local def = songDef(data, song)
if not def or state.failed[song] then return end if not def or state.failed[song] then return end
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
slowed.tempo = tempo slowed.tempo = tempo
def = slowed def = slowed
end 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 wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop) local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then 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.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song state.current = song
state.tempo = tempo state.tempo = tempo
state.start = start
if Runtime.wants("music.started") then if Runtime.wants("music.started") then
Runtime.emit("music.started", { Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip, song = song, previous = previous, chip = isChip,
@@ -290,7 +305,7 @@ function Music.stop()
stopSource(state.loopSource) stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic() require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil 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.chip = false
state.pendingRestore = nil state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then if previous and Runtime.wants("music.stopped") then
+8
View File
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
local args = { ... } local args = { ... }
if #args == 0 then return text end 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 local slots, named = 0, 0
for token in text:gmatch("%b{}") do for token in text:gmatch("%b{}") do
slots = slots + 1 slots = slots + 1
+63 -8
View File
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
-- options.lua written before this key keeps its exact meaning. Read and -- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled. -- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {}, 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 -- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is -- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }. -- { 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 -- 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. -- 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 -- Per-game answers are live. Every reader and writer goes through modScope,
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this -- so a choice made in the launcher is the choice the next boot loads.
-- flips on with that read and not before: until then every writer keeps to the SaveData.PER_VERSION_MODS = true
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- The version a write should be scoped to: the game asked for once per-game -- The version a write is scoped to: per-game controls name one game; a nil
-- flags are live, nil (the shared flag) while they are only a preview. -- caller still addresses the legacy shared fallback.
function SaveData.modScope(version) function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end if SaveData.PER_VERSION_MODS then return version end
return nil return nil
end 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 -- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps -- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked). -- experimental mods off until asked).
@@ -1834,6 +1887,8 @@ function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F" local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6 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 heal = SaveData.defaultHeal(boot)
local save = { local save = {
meta = { format = Version.saveFormat, mods = {} }, meta = { format = Version.saveFormat, mods = {} },
@@ -1844,7 +1899,7 @@ function SaveData.newGame(boot)
map = map, map = map,
x = x, x = x,
y = y, y = y,
facing = boot.startFacing or "down", facing = facing,
name = boot.playerName or "RED", name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE", rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from -- 16-bit trainer ID rolled at new game (wPlayerID, filled from
+14
View File
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data) return rareCandy(ctx.mon, ctx.data)
end) 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 for itemId in pairs(ItemEffects.REVIVE) do
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end) record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
end end
+308 -40
View File
@@ -100,6 +100,7 @@ end
-- other half of that guarantee. -- other half of that guarantee.
function LauncherView.update(imp, dt) function LauncherView.update(imp, dt)
if not imp._flex then return end if not imp._flex then return end
if imp._launchFade then return end
local down = false local down = false
if love.mouse and love.mouse.isDown then if love.mouse and love.mouse.isDown then
@@ -312,6 +313,236 @@ local function cartColor(version)
return CART_COLOR[version] or PAL.green return CART_COLOR[version] or PAL.green
end 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) local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end if status == "ok" then return Strings("Ready"), PAL.green end
if status == "conflict" then return Strings("Conflict"), PAL.red 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 else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
local tagH = Kit.textHeight("micro") + math.floor(10 * 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)) local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
+ math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol) + 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 cy = y + titleH + math.floor(12 * m.s)
local remaining = availH - (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 local ly = cy
if ready then if ready then
-- Play IS the panel: it takes the space the ROM buttons used to hold, at -- The cartridge takes the Play button's former place. Its portrait
-- the top of the column where the eye lands, wearing this game's own -- ratio comes from a real Game Boy cart rather than stretching the old
-- cartridge colour rather than a generic green. -- horizontal control, and its body colour comes from the active game.
-- Play grows into the room the column has: it is the one thing on this local playH = math.floor(clamp(remaining * 0.52, 112 * m.s, 260 * m.s))
-- screen the player came for, and the space freed by moving ROM and local mgW = math.max(Kit.tapMin(), math.floor(34 * m.s))
-- 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
local bgap = math.floor(8 * m.s) local bgap = math.floor(8 * m.s)
btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version, local cartAreaW = lw - mgW - bgap
Strings("Play ") .. gameName, { local cartW = math.min(cartAreaW, math.floor(playH * 0.88))
fill = cartColor(version), ink = PAL.inverse, font = "stat", local cartX = lx + math.floor((cartAreaW - cartW) / 2)
action = function() imp:play(version) end, cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version,
}) version, gameName, function() imp:play(version, true) end)
imp._gearIcon = imp._gearIcon imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png") 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) imp._gearIcon, function() imp._gameManage = version end)
ly = ly + playH + gap ly = ly + playH + gap
end end
@@ -1139,6 +1373,28 @@ local function drawCheck(x, y, size, color)
love.graphics.pop() love.graphics.pop()
end 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) local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods() imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
@@ -1225,15 +1481,14 @@ local function buildModsPanel(imp, x, y, w, availH, m)
mods = sorted mods = sorted
end end
-- A mod row is a fixed height: name line, version + status line, one line -- A mod row is a fixed height: its details first, then a dedicated second
-- of description, and an action row. Fixed because a page of uniform rows -- line of per-game checkboxes. Fixed because a page of uniform rows is
-- is what lets perPage come from the viewport. -- what lets perPage come from the viewport.
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local togH = math.floor(26 * m.s)
-- Text block on the left, chips right-aligned beside it: one row, not a local gamesLabel = Strings("Enable for:")
-- text block with a button strip stacked under it.
local textH = Kit.textHeight("button") + math.floor(4 * m.s) local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") + 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) + math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap 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 px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s) local ly = ry + math.floor(10 * m.s)
local togW = math.floor(56 * m.s) local togGap = math.floor(4 * m.s)
local togH = math.floor(26 * m.s)
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local togKey = "mod-toggle-" .. mod.id -- These answer separate games, not a single shared install flag. The
-- The toggle reports its own new value, but the importer owns the state: -- importer receives the game id so an experimental confirmation also
-- queue the flip and let _toggleMod (which may raise an experimental-mod -- applies only to the checkbox the player pressed.
-- confirm) decide what actually happens. local flipped = false
local _, flipped = Kit.toggle(px + inner - togW, local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey) Kit.text("micro", gamesLabel, px,
if flipped then gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end) 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 end
-- The toggle sits inside the row's rect, so its press also passes the -- The checkboxes sit inside the row's rect, so their press also passes the
-- row's hit test; `flipped` gates the row action to everywhere else. -- row hit test; `flipped` gates the row action to everywhere else.
if not flipped if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end) queueAction(imp, rowKey, function() imp._modActions = id end)
end end
local chipsW = togW + math.floor(6 * m.s) local textW = inner
local textW = inner - chipsW - math.floor(12 * m.s)
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) 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 -- 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 elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true) imp:_importSave(c.version, c.source, true)
else else
imp:_toggleMod(c.id, true) imp:_toggleMod(c.id, true, c.version)
end end
end, end,
}) })
@@ -2608,6 +2871,11 @@ function LauncherView.draw(imp)
end end
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() Kit.endFrame()
drawPadCursor(imp) drawPadCursor(imp)
end end
+27 -8
View File
@@ -2048,6 +2048,15 @@ end
function RomImporter:update(dt) function RomImporter:update(dt)
self.pulse = self.pulse + 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:_updatePadCursor(dt)
self:_stepBaseRomScan() self:_stepBaseRomScan()
-- Pump the FlexLove view (input polling + the queued click actions). The -- Pump the FlexLove view (input polling + the queued click actions). The
@@ -2436,9 +2445,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
end end
-- Player pressed Play on a game whose ROM is imported: hand off to boot. -- 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 self.workState == "working" then return end
if not self.ready[version] 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 self._handedOff = true
-- #835: remember the game being launched so the next launcher start opens on -- #835: remember the game being launched so the next launcher start opens on
-- its column (_applyLastVersionTab). It rides options.lua rather than a file -- 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 return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end end
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist -- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so the toggle, count, and every status chip reflect the new resolution. -- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirm first. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed) function RomImporter:_toggleMod(id, confirmed, version)
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
if m.id == id then 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 experimental = m.experimental == true
break break
end end
@@ -3080,7 +3099,7 @@ function RomImporter:_toggleMod(id, confirmed)
local want = not cur local want = not cur
if want and experimental and not confirmed then if want and experimental and not confirmed then
self._modConfirm = { self._modConfirm = {
kind = "experimental", id = id, kind = "experimental", id = id, version = version,
title = "Experimental mod", title = "Experimental mod",
yesLabel = "Enable", yesLabel = "Enable",
lines = { lines = {
@@ -3092,7 +3111,7 @@ function RomImporter:_toggleMod(id, confirmed)
return return
end end
self._modConfirm = nil self._modConfirm = nil
LauncherMods.setEnabled(id, want, self.modScope) LauncherMods.setEnabled(id, want, version or self.modScope)
self:_refreshMods() self:_refreshMods()
end end
+11
View File
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed. -- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
if itemId == "POKE_FLUTE" then if itemId == "POKE_FLUTE" then
if not battle 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 -- standing next to a not-yet-beaten Snorlax: this is the ONLY way
-- Snorlax wakes -- using the flute from the item-use menu, never -- Snorlax wakes -- using the flute from the item-use menu, never
-- just talking to it with the flute in the bag (see -- just talking to it with the flute in the bag (see
+44 -11
View File
@@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local LauncherMods = {} local LauncherMods = {}
@@ -107,8 +108,9 @@ end
-- deriveList(manifests, options [, version]) -> the panel row list, pure. -- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output); -- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and -- options is the options table (options.mods, options.modsByVersion and
-- options.modsGen2 are read). `version` is the game the panel is showing: -- 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. -- 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. -- Rows come back sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options, version) function LauncherMods.deriveList(manifests, options, version)
local ordered = {} local ordered = {}
@@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- this game's choice, then the shared flag, then the default: enabled, -- this game's choice, then the shared flag, then the default: enabled,
-- matching the loader -- except experimental mods, which stay off until -- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what -- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads: while per-game flags are a -- setEnabled writes and the loader loads for the selected game.
-- preview the shared flag is the whole answer on every surface.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end if decided then enabledSet[m.id] = true end
@@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version)
badge = badge, badge = badge,
description = m.description or "", description = m.description or "",
enabled = enabled, 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, status = status,
statusDetail = detail, statusDetail = detail,
github = m.github, github = m.github,
@@ -281,7 +290,14 @@ end
function LauncherMods.list(version) function LauncherMods.list(version)
local ok, result = pcall(function() local ok, result = pcall(function()
local options = SaveData.loadOptions() 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) end)
if not ok then if not ok then
-- a single bad options/mod file must not blank the launcher -- a single bad options/mod file must not blank the launcher
@@ -364,10 +380,8 @@ function LauncherMods.translationStrings()
return merged return merged
end end
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact -- setEnabled(id, enabled [, version]): with a game, persist just that game's
-- shape Loader:_saveState writes (a plain boolean), so the running game and -- answer. The loader and the in-game manager use the same scope on next boot.
-- the in-game ManagerState pick it up unchanged. With `version` the choice
-- lands in that game's overlay instead and no other game moves.
function LauncherMods.setEnabled(id, enabled, version) function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
@@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local scope = SaveData.modScope(version) local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do 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 end
SaveData.saveOptions(options) SaveData.saveOptions(options)
return true return true
@@ -843,7 +865,7 @@ end
-- uninstall(id) -> true | nil, errString -- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder -- Removes mods/<id>/ 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. -- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state. -- Does not touch other mods' enable state.
function LauncherMods.uninstall(id) 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 -- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false. -- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local changed = false
if options.mods and options.mods[id] ~= nil then if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil 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) SaveData.saveOptions(options)
end end
return true return true
+20 -4
View File
@@ -270,8 +270,7 @@ function Loader:_targetVersion()
return version return version
end end
-- The version an enable flag is read and written under: this game once -- The version an enable flag is read and written under: this running game.
-- per-game flags are live, nil (the shared flag) while they are a preview.
-- Reads and writes go through the same answer so the two can never drift. -- Reads and writes go through the same answer so the two can never drift.
function Loader:_enableScope() function Loader:_enableScope()
return SaveData.modScope(self:_targetVersion()) return SaveData.modScope(self:_targetVersion())
@@ -1306,13 +1305,30 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation) require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState() self:_loadState()
self:_discover() 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 -- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that. -- options.mods entry normally means enabled, but experimental flips that.
do do
local options = SaveData.loadOptions(self.fs) local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {} local scope = self:_enableScope()
for id, mod in pairs(self.mods) do 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 and mod.manifest.experimental then
self.disabled[id] = true self.disabled[id] = true
end end
+1 -2
View File
@@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m)
}, apply) }, apply)
end end
-- Where the loader persisted an enable flag: this game's slot once it keeps -- Where the loader persists an enable flag: this running game's slot.
-- them per game, the shared flag until then (SaveData.modScope).
function ManagerState:enableScope() function ManagerState:enableScope()
return SaveData.modScope((self:targetGame())) return SaveData.modScope((self:targetGame()))
end end
+1
View File
@@ -140,6 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
require("src.core.Sound").play(game.data, "Get_Item1")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text", romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep)) "%s learned\n%s!", name, mdef.name), nextStep))
+6 -1
View File
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
return obpCache[key] return obpCache[key]
end end
SpriteRenderer.obpImage = getObpImage
-- hot reload drops the sheets; live instances hold their own image, so -- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this -- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate() function SpriteRenderer.invalidate()
@@ -281,7 +283,7 @@ end
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional -- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
-- and trailing, so every existing call site is unchanged. -- and trailing, so every existing call site is unchanged.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, 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 x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image local image = self.image
local redraw = false 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, -- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames. -- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip) 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 if forceFlip then flip = true end
local quad = self.frames[frame] local quad = self.frames[frame]
local drawHeight = self.frameHeight local drawHeight = self.frameHeight
+1
View File
@@ -130,6 +130,7 @@ TextBox.TOKENS = {
STRBUF = function(game) return game.stringBuffer end, STRBUF = function(game) return game.stringBuffer end,
RAM = function(game, arg) RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wNameBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here -- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end if arg == "wBoxMonNicks" then return game.boxMonNicks end
+18
View File
@@ -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 -- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
-- 0x2852 (#763, #857). -- 0x2852 (#763, #857).
O.toggleObjectFlags = O.coins + 2 -- 32B O.toggleObjectFlags = O.coins + 2 -- 32B
O.hiddenItemFlags = O.townVisited - 27
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified -- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
end end
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: -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- 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 -- 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
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 -- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save -- 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 -- table with no `visited` key at all says nothing about the set, so leave
+7
View File
@@ -49,6 +49,7 @@ local DATA_MODULES = {
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, 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" }, 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" }, 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 } 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",
"src/save_convert/data/event_flags_yellow.lua", "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 function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath) local ok, mod = pcall(require, requirePath)
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
if name ~= "charmap" then if name ~= "charmap" then
if name == "eventFlags" and gameVersion == "yellow" then if name == "eventFlags" and gameVersion == "yellow" then
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838) spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
elseif name == "hiddenItems" and gameVersion == "yellow" then
spec = YELLOW_HIDDEN_ITEMS
end end
local mod = loadCacheTable(gameVersion, spec[2]) local mod = loadCacheTable(gameVersion, spec[2])
if not mod then if not mod then
+30
View File
@@ -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 },
}
@@ -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 },
}
+17 -3
View File
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value ctx.save[key] = value
end 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 function toggleObject(ctx, mapId, objName, visible)
local save = ctx.save local save = ctx.save
save.objectToggles = save.objectToggles or {} save.objectToggles = save.objectToggles or {}
@@ -1131,9 +1147,7 @@ end
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm -- 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). -- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
function Commands.play_music(ctx, songId, opts) function Commands.play_music(ctx, songId, opts)
local tempo = opts and opts.tempo require("src.core.Music").play(ctx.game.data, songId, nil, opts)
require("src.core.Music").play(ctx.game.data, songId, nil,
tempo and { tempo = tempo } or nil)
if opts and opts.keep and ctx.overworld then if opts and opts.keep and ctx.overworld then
ctx.overworld.keepMusicOnce = true ctx.overworld.keepMusicOnce = true
end end
+19 -7
View File
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return return
end 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, -- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then if result == "flute_wake" then
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
list:close() list:close()
local ow = game.overworld local ow = game.overworld
local p = ow and ow.player local p = ow and ow.player
if ow and p then if ow and p and ow:facingIsShoreOrWater() then
local fx, fy = p:facingCell() ow:goFishing(id)
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then return
ow:goFishing(id)
return
end
end end
showMessages(game, { Strings("No good! It's not\neven near water.") }) showMessages(game, { Strings("No good! It's not\neven near water.") })
return return
@@ -176,19 +181,25 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end end
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) 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 showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) }) game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
taught() taught()
else else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned) function(learned)
if learned and result == "learn" then consume(game, id) end 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 if learned then taught() end
end) end)
end end
end end
list:close()
teach() teach()
return return
end end
@@ -322,6 +333,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId] local mdef = game.data.moves[moveId]
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) 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 local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep) nextStep)
+17
View File
@@ -119,6 +119,13 @@ local function deposit(game)
onChoose = function(item, list) onChoose = function(item, list)
local mon = game.save.party[item.value] local mon = game.save.party[item.value]
if not mon then return end 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() monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then if #game.save.party <= 1 then
list.footer = Strings("You need at least\none POKéMON!") list.footer = Strings("You need at least\none POKéMON!")
@@ -167,6 +174,16 @@ local function release(game)
local mon = box[list.index] local mon = box[list.index]
if not mon then return end if not mon then return end
local name = monName(game, mon) 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, game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
defaultNo = true, noSound = true, defaultNo = true, noSound = true,
+7 -2
View File
@@ -17,12 +17,13 @@ local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, 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) local self = setmetatable({}, MoveLearnMenu)
self.game = game self.game = game
self.mon = mon self.mon = mon
self.newMoveId = newMoveId self.newMoveId = newMoveId
self.onDone = onDone self.onDone = onDone
self.learnedSound = learnedSound or "Get_Item1"
self.index = 1 self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop) -- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false self.selecting = false
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
self.selecting = false self.selecting = false
game.stack:pop() game.stack:pop()
local msg local msg
local opts
if learned then if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries -- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail -- the "And..." tail
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
"\f%s forgot\n%s!\fAnd...", name, self.forgot) "\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text", .. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name) "%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 else
msg = romText(game.data, "_DidNotLearnText", msg = romText(game.data, "_DidNotLearnText",
"%s\ndid not learn\v%s!", name, mdef.name) "%s\ndid not learn\v%s!", name, mdef.name)
end end
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
if self.onDone then self.onDone(learned) end if self.onDone then self.onDone(learned) end
end)) end, opts))
end end
function MoveLearnMenu:draw() function MoveLearnMenu:draw()
+23
View File
@@ -72,6 +72,21 @@ end
local function sameItems(_, items) return items 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 -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope) -- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -367,6 +382,10 @@ function PartyMenu:update(dt)
self.submenu = nil self.submenu = nil
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
local mon = party[self.index] local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
local entry = self.subItems[self.subIndex] local entry = self.subItems[self.subIndex]
local action = entry.action local action = entry.action
if not action and entry.onSelect then if not action and entry.onSelect then
@@ -577,6 +596,10 @@ function PartyMenu:update(dt)
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
elseif input:wasPressed("a") and #party > 0 then elseif input:wasPressed("a") and #party > 0 then
local mon = party[self.index] local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
if self.softboiledFrom then if self.softboiledFrom then
local user = party[self.softboiledFrom] local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5) local heal = math.floor(user.stats.hp / 5)
+21
View File
@@ -104,6 +104,27 @@ local function sell(game)
dialogue = true, dialogue = true,
money = function() return game.save.money end, money = function() return game.save.money end,
footer = greet, 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) onChoose = function(item)
local def = game.data.items[item.value] local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem / -- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
+2 -1
View File
@@ -34,7 +34,8 @@ function StartMenu.new(game)
-- POKéMON is always listed (draw_start_menu.asm prints it even with -- POKéMON is always listed (draw_start_menu.asm prints it even with
-- an empty party; selecting it then just no-ops) -- 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 if #game.save.party == 0 then return end
Screens.push(game, "PartyMenu", { onCancel = reopen }) Screens.push(game, "PartyMenu", { onCancel = reopen })
end }) end })
+15 -8
View File
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
self.versionFull = imagePath(title.versionRibbon) ~= nil self.versionFull = imagePath(title.versionRibbon) ~= nil
self.version = tryImage(imagePath(title.versionRibbon or title.version) self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png") or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player) self.playerPath = imagePath(title.player)
or "assets/generated/title/player.png") or "assets/generated/title/player.png"
self.player = tryImage(self.playerPath)
-- ..(engine/movie/title2.asm ln 85) -- ..(engine/movie/title2.asm ln 85)
if self.player then if self.player then
local pw, ph = self.player:getDimensions() local pw, ph = self.player:getDimensions()
@@ -597,6 +598,12 @@ end
-- ..(engine/movie/title.asm ln 28) -- ..(engine/movie/title.asm ln 28)
function TitleState:draw() 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.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = -(self.scy or 0) 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). -- layout has no cycling mon and no Red art (title_yellow.asm).
if spriteTrueColor then if spriteTrueColor then
local cover local cover
if self.player then if playerImage then
local pw, ph = self.player:getDimensions() local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph } cover = { 82, 80, pw, ph }
end end
markVisibleTrueColor(x, y, w, h, cover) 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 -- Red is OAM in the original: he draws over the mon's box edge
if self.playerQuads then if self.playerQuads then
for _, part in ipairs(self.playerQuads) do 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 end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY) love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
elseif self.player then elseif playerImage then
love.graphics.draw(self.player, 82, 80) love.graphics.draw(playerImage, 82, 80)
end end
end end
self:drawCopyright(136 + (preRibbon and 0 or scrollY)) self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+11 -3
View File
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
evolved.level, evolved) evolved.level, evolved)
end 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() function EvolutionAnim:nextLearn()
self.learnIndex = (self.learnIndex or 0) + 1 self.learnIndex = (self.learnIndex or 0) + 1
local moveId = self.pending and self.pending[self.learnIndex] local moveId = self.pending and self.pending[self.learnIndex]
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
self.learned[#self.learned + 1] = moveId self.learned[#self.learned + 1] = moveId
self.lines = { self.nick .. " learned", moveName .. "!" } self.lines = { self.nick .. " learned", moveName .. "!" }
elseif reason == "full" then 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.full[#self.full + 1] = moveId
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" } self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
else else
+2 -1
View File
@@ -128,7 +128,8 @@ end
function NPC:draw(camX, camY) function NPC:draw(camX, camY)
local sprite, px, py, facing, phase, flip = self:pose() 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 end
return NPC return NPC
+22 -2
View File
@@ -1087,6 +1087,7 @@ function OverworldState:update(dt)
-- the player lands on desk Oak. -- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0 local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
if not scripted and not self.transitioning then if not scripted and not self.transitioning then
self:checkTrainerSight() self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and -- 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. -- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0 scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end end
if not scripted and not self.transitioning then if not scripted and not self.transitioning then
self:handleInput() self:handleInput()
@@ -1664,6 +1666,10 @@ end
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group -- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
-- (no group means "Not even a nibble!"). -- (no group means "Not even a nibble!").
function OverworldState:goFishing(rod) 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 pool, always = fishingPool(Game.data, rod, self.map.id)
local enc local enc
if Runtime.wantsHook("encounter.fishing") then if Runtime.wantsHook("encounter.fishing") then
@@ -2905,6 +2911,11 @@ end
-- POKéMON" and "fighting fit". -- POKéMON" and "fighting fit".
function OverworldState:nurseHeal(onDone, npc) function OverworldState:nurseHeal(onDone, npc)
local t = Game.data.text 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 bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
local hello = t._PokemonCenterWelcomeText local hello = t._PokemonCenterWelcomeText
or Strings("Welcome to our\nPOKéMON CENTER!") or Strings("Welcome to our\nPOKéMON CENTER!")
@@ -2978,9 +2989,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end)) end))
end end
if not npc then farewell() return 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 -- 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))
end end
@@ -2992,6 +3007,11 @@ end
-- for the original serial handshake; declining prints "Please come again!" -- for the original serial handshake; declining prints "Please come again!"
function OverworldState:cableClubReceptionist(onDone) function OverworldState:cableClubReceptionist(onDone)
local t = Game.data.text 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!") local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
if not Game.save.flags.EVENT_GOT_POKEDEX then if not Game.save.flags.EVENT_GOT_POKEDEX then
-- CableClubNPC .didNotConnect path before the pokedex -- CableClubNPC .didNotConnect path before the pokedex
+43 -1
View File
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
return nil return nil
end 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 -- ModifyPikachuHappiness. mon is the party mon the event applied to for
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and -- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
-- WALKING instead require any healthy starter in the party -- 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 -- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it. -- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil ow.pikachuBillsScene = nil
ow.pikachuFanClubScene = nil
remove(ow) remove(ow)
if not shouldSpawn(game, ow) then return end if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive: -- opts.keepPikachu is the follower a connection crossing kept alive:
@@ -410,7 +422,8 @@ end
-- (pikachu_follow.asm keeps it one walk step behind) -- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow) function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417) 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) local npc = findFollower(ow)
if not npc then if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end 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 ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
local save = game.save local save = game.save
local emotion = selectEmotion(game, ow, 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] local e = EMOTIONS[emotion] or EMOTIONS[1]
if e.turnAway then if e.turnAway then
npc.facing = ow.player.facing -- pikaemotion_9: back to the player npc.facing = ow.player.facing -- pikaemotion_9: back to the player
@@ -798,6 +818,28 @@ local function movePikachu(ow, npc, steps, onDone)
nextStep(1) nextStep(1)
end 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) function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return return
+82
View File
@@ -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")
+18 -6
View File
@@ -56,10 +56,24 @@ do
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } })) local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled") 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(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.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end 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 -- ------- experimental defaults to disabled; github surfaces on the row
do do
@@ -291,10 +305,9 @@ end
-- ------- enable flags: the panel reads exactly what the switch writes -- ------- enable flags: the panel reads exactly what the switch writes
-- --
-- One scope for both halves (SaveData.modScope). While per-game flags are a -- One scope for both halves (SaveData.modScope). Per-game flags are live, so
-- preview the shared flag is the whole answer, so a modsByVersion overlay -- -- a modsByVersion answer -- including one restored from a .g1rmodlist -- is
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can -- both what the launcher shows and what the next boot reads.
-- never leave the switch showing an answer no writer can reach.
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
@@ -309,9 +322,8 @@ do
} }
local planted = { mods = { one = true }, local planted = { mods = { one = true },
modsByVersion = { gold = { one = false } } } modsByVersion = { gold = { one = false } } }
local expected = SaveData.PER_VERSION_MODS and false or true
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, 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 -- the round trip, the thing the dead switch failed: flip it, re-derive
local options = { mods = {} } local options = { mods = {} }
+26
View File
@@ -162,6 +162,32 @@ do
"modScope follows the PER_VERSION_MODS switch") "modScope follows the PER_VERSION_MODS switch")
end 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 -- ------- a profile carries the per-game half of a setup
do do
+5 -4
View File
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
pushed[1].onDone() pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow") 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") T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {} local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse") 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") T.eq(#pushed, 2, "the farewell follows the bow")
local farewell = pushed[2] or {} local farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "second box is the farewell text") 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 if farewell.onDone then farewell.onDone() end
T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back") T.eq(nurse.facing, "down", "the trailing UpdateSprites keeps her facing")
T.eq(faced, 1, "she is turned back exactly once") 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") 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 -- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text
+55
View File
@@ -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")
+113
View File
@@ -58,6 +58,8 @@ local MATCHUPS = {
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, { attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
{ attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, { attacker = "NORMAL", defender = "STEEL", multiplier = 5 },
{ attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, { 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 = { local MOVES = {
@@ -144,6 +146,15 @@ local POKEMON = {
levelMoves = { { level = 1, move = "TACKLE" } }, levelMoves = { { level = 1, move = "TACKLE" } },
evolutions = {}, 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 = { local DATA = {
@@ -613,6 +624,21 @@ local battle, player, wild = newBattle()
check("battle picks the first healthy mon", battle.player, player) check("battle picks the first healthy mon", battle.player, player)
check("wild battle flag", battle.wild, true) 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. -- A move spends PP and deals damage.
local before = wild.hp local before = wild.hp
battle:takeTurn({ kind = "move", move = "TACKLE" }) battle:takeTurn({ kind = "move", move = "TACKLE" })
@@ -910,6 +936,16 @@ local EFFECT_MOVES = {
SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1, SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1,
type = "FIGHTING", accuracy = 100, pp = 20, type = "FIGHTING", accuracy = 100, pp = 20,
effect = "EFFECT_LEVEL_DAMAGE" }, 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", LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL",
accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" }, accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" },
-- data/moves/moves.asm:169, :172, :151. -- data/moves/moves.asm:169, :172, :151.
@@ -1078,6 +1114,83 @@ tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" })
check("seismic toss deals the level", check("seismic toss deals the level",
tossBefore - tossWild.hp, tossPlayer.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 -- --------------------------------------------------------------- held items
local Ai = require("src.battle.gen2.Ai") local Ai = require("src.battle.gen2.Ai")
+4 -2
View File
@@ -1017,8 +1017,10 @@ press(ms, "select")
check(avail[1].enabled == false, "SELECT quick-toggles the focused mod") 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:isStaged(avail[1]), "a flip against boot state is staged")
check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph") check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
check(mgame.save.options.mods.badmod == false, local managerScope = ms:enableScope()
"the live options table mirrors the flip") 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") check(ms.restartPending, "staged changes arm the apply screen")
ms:discardChanges() ms:discardChanges()
check(avail[1].enabled == true and not ms.restartPending, check(avail[1].enabled == true and not ms.restartPending,
+3 -3
View File
@@ -60,14 +60,14 @@ for _, r in ipairs(rows) do
end end
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame") 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). -- the player does not visibly pass through the rival at (4,2).
local route = {} local route = {}
for _, r in ipairs(rows) do for _, r in ipairs(rows) do
if r[1] == "move_player" then route[#route + 1] = r end if r[1] == "move_player" then route[#route + 1] = r end
end end
eq(route[#route - 1] and route[#route - 1][2], "right", eq(route[#route - 1] and route[#route - 1][2], "left",
"walk-out route first moves right around the rival") "walk-out route first moves left around the rival")
eq(route[#route] and route[#route][2], "up", eq(route[#route] and route[#route][2], "up",
"walk-out route then heads north to Hall of Fame") "walk-out route then heads north to Hall of Fame")
+3 -3
View File
@@ -203,13 +203,13 @@ do
eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes") eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes")
end end
-- the OPTIONS animation toggle still gates the whole thing; the sound does not -- engine/battle/animations.asm PlayApplyingAttackAnimation
do do
local tb = freshBattle() local tb = freshBattle()
Game.save.options.animations = false Game.save.options.animations = false
tb:applyHitFx({ animType = 5, sfx = "Damage" }) tb:applyHitFx({ animType = 5, sfx = "Damage" })
eq(tb.fx.shakeProg, nil, "animations off arms no shake") check(tb.fx.shakeProg ~= nil, "animations off keeps the hit shake")
eq(tb.fx.blink, nil, "and no blink") eq(tb.fx.blink, nil, "type 5 remains a shake, not a blink")
Game.save.options.animations = true Game.save.options.animations = true
end end
+93
View File
@@ -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()
+6 -7
View File
@@ -891,8 +891,7 @@ do
check(tb:lockedAction(tb.enemy) == nil, "victim is free after the release") check(tb:lockedAction(tb.enemy) == nil, "victim is free after the release")
end end
-- #2: recoil and drain use the RAW computed damage, not the HP-capped -- engine/battle/core.asm ApplyDamageToEnemyPokemon
-- amount dealt
do do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local rb = BattleState.newWild(Game, "RATTATA", 3) local rb = BattleState.newWild(Game, "RATTATA", 3)
@@ -903,8 +902,8 @@ do
rb.rng = mkseq({ 0, 255, 255 }) rb.rng = mkseq({ 0, 255, 255 })
local hpBefore = rb.player.mon.hp local hpBefore = rb.player.mon.hp
rb:performMove(rb.player, rb.enemy, { id = "TAKE_DOWN", pp = 10 }) rb:performMove(rb.player, rb.enemy, { id = "TAKE_DOWN", pp = 10 })
eq(hpBefore - rb.player.mon.hp, math.floor(raw / 4), eq(hpBefore - rb.player.mon.hp, 1,
"recoil is raw damage / 4 even when only 1 HP was dealt") "recoil is capped damage / 4 with a minimum of 1")
local db = BattleState.newWild(Game, "RATTATA", 3) local db = BattleState.newWild(Game, "RATTATA", 3)
db.enemy.mon.hp = 1 db.enemy.mon.hp = 1
@@ -914,9 +913,9 @@ do
check(rawD >= 4, "raw MEGA DRAIN damage is meaningful (" .. rawD .. ")") check(rawD >= 4, "raw MEGA DRAIN damage is meaningful (" .. rawD .. ")")
db.rng = mkseq({ 0, 255, 255 }) db.rng = mkseq({ 0, 255, 255 })
db:performMove(db.player, db.enemy, { id = "MEGA_DRAIN", pp = 10 }) db:performMove(db.player, db.enemy, { id = "MEGA_DRAIN", pp = 10 })
eq(db.player.mon.hp - 1, math.floor(rawD / 2), eq(db.player.mon.hp - 1, 1,
"drain heals raw damage / 2 even when only 1 HP was dealt") "drain heals capped damage / 2 with a minimum of 1")
eq(db.lastDamage, math.floor(rawD / 2), eq(db.lastDamage, 1,
"drain halves wDamage in place (Counter would see the half)") "drain halves wDamage in place (Counter would see the half)")
end end