diff --git a/README.md b/README.md index 175e8c90..ed4d9a97 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # Pokemon Gen 1 Recompilation Project -A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are -hand-written Lua; game data and graphics are decoded from a ROM supplied by -the player. +A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map +behavior are hand-written Lua; game data and graphics are decoded from a ROM +supplied by the player. SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu) This project does not include a ROM, emulate the Game Boy, transpile assembly, -or download a disassembly. A canonical US Pokemon Red ROM is the only game -content input. +or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only +game content input. The ROM is verified, used during import, and then released from memory. It is not copied into the cache. Later launches load the private generated cache and -do not ask for the ROM again. +do not ask for the ROM again. Red and Blue can both be imported and played +side by side. ## Quick Start @@ -20,8 +21,12 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file or drop it onto the window. Import takes a few seconds and the game starts automatically. -Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1 -`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data. +Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer +verifies SHA-1 before creating any game data: + +- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` +- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` + The packaged app contains neither a ROM nor pre-extracted game data. Music, sound effects, and cries are synthesized while the game runs from compact audio channel programs copied out of the verified ROM. @@ -33,11 +38,11 @@ B; Escape opens START. F1 saves and F2 loads. Controllers are supported. ## Running From Source -Requires LÖVE 11.x. Place the ROM in the project folder and double-click -`Play-Mac.command` or `Play-Windows.bat`, or run: +Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and +double-click `Play-Mac.command` or `Play-Windows.bat`, or run: ```sh -scripts/setup.sh --rom "/path/to/Pokemon Red.gb" +scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb scripts/run.sh ``` @@ -107,7 +112,3 @@ request with real detail is one that can actually get built. This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their [pokered](https://github.com/pret/pokered) disassembly. - -## Wanna Support My Work? -[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi) - diff --git a/data/scripts/flavor/reds_house_2f.lua b/data/scripts/flavor/reds_house_2f.lua new file mode 100644 index 00000000..357f2015 --- /dev/null +++ b/data/scripts/flavor/reds_house_2f.lua @@ -0,0 +1,22 @@ +-- Red's House 2F (pokered data/events/hidden_events.asm + +-- engine/events/hidden_objects PrintRedSNESText). +-- +-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that +-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map +-- but skips PrintRedSNESText, so interaction was a no-op (#135). + +local TextBox = require("src.render.TextBox") + +return { + REDS_HOUSE_2F = { + -- hidden_events.asm: + -- hidden_event 3, 5, PrintRedSNESText, ANY_FACING + onInteract = function(game, ow, fx, fy) + if fx ~= 3 or fy ~= 5 then return false end + local text = game.data.text._RedBedroomSNESText + or "{PLAYER} is\nplaying the SNES!" + game.stack:push(TextBox.new(game, text)) + return true + end, + }, +} diff --git a/data/scripts/flavor_all.lua b/data/scripts/flavor_all.lua index efb5d45f..2d03324d 100644 --- a/data/scripts/flavor_all.lua +++ b/data/scripts/flavor_all.lua @@ -28,6 +28,7 @@ local files = { "data.scripts.flavor.pokemon_fan_club", "data.scripts.flavor.power_plant", "data.scripts.flavor.reds_house_1f", + "data.scripts.flavor.reds_house_2f", "data.scripts.flavor.route11_gate_2f", "data.scripts.flavor.route18_gate_2f", "data.scripts.flavor.route_12_gate_2f", diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 20082832..c092467f 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -31,13 +31,16 @@ local function starterBall(askText, species, choseFlag, ownBall, { species = species, forceOwned = true } }, -- 5 { "ask", askText }, -- 6 { "jump_if_false", 21 }, -- 7 - { "give_pokemon", species, 5 }, -- 8 - { "set_flag", "EVENT_GOT_STARTER" }, -- 9 - { "set_flag", choseFlag }, -- 10 + -- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives + -- inside give_pokemon). Show the received text first so the + -- nickname prompt follows "you got X", matching Gen1. + { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8 + { "give_pokemon", species, 5 }, -- 9 + { "set_flag", "EVENT_GOT_STARTER" }, -- 10 + { "set_flag", choseFlag }, -- 11 -- POKé BALLs are not handed out here in the original -- Oak gives -- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) - { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11 { "hide_object", "OAKS_LAB", ownBall }, -- 12 -- the rival walks to the countering ball (around the furniture) { "move_npc_to", 1, rivalBallX, 4 }, -- 13 diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 2b859fde..1920f0ee 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -616,7 +616,8 @@ M.SILPH_CO_11F = { -- ------------------------------------------------------------------- -- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): -- a boulder resting on a switch removes a barrier block; the 3F hole --- drops a boulder down to the 2F switch. +-- drops a boulder down to the 2F switch, and also dungeon-warps the +-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16). -- ------------------------------------------------------------------- local function boulderAt(ow, x, y) @@ -682,6 +683,18 @@ M.VICTORY_ROAD_3F = { Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") end end, + -- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole + -- is a dungeon warp for the player (wDungeonWarpDestinationMap = + -- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16). + -- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the + -- fall is onStep, not a collision block. + onStep = function(game, ow, x, y) + if x == 23 and y == 15 then + ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing) + return true + end + return false + end, } -- ------------------------------------------------------------------- @@ -694,6 +707,16 @@ M.VICTORY_ROAD_3F = { -- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches, -- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set -- forever (postgame gates like the Cerulean cave guard read it). +-- +-- pokered forces the fight on map entry: Agatha's victory arms +-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and +-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement +-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript. +-- The rival object has no trainer header / sight range, so without that +-- entrance script the player can walk past (issue #99). We arm on the +-- run flag instead of Agatha's victory bit: same observable effect for +-- first clear and Indigo rematches, without a cross-map script pointer. +-- -- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript -> -- OakArrivesScript -> OakCongratulatesPlayerScript -> -- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript, @@ -702,43 +725,69 @@ M.VICTORY_ROAD_3F = { -- itself is NOT run here: it belongs to the HALL_OF_FAME room script -- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room -- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record. +local championsRoomRivalScript = { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 + { "jump_if_true", 25 }, -- 3 past end + { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 + { "rival_battle", "OPP_RIVAL3", 1 }, -- 5 + { "jump_if_false", 25 }, -- 6 past end + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 + -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, + -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = + -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText + -- is the port's generic " defeated BLUE!" engine line instead). + { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 + -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo + -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after + -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in + { "play_music", "Music_Cities1", { keep = true } }, -- 10 + { "show_text", "_ChampionsRoomOakText" }, -- 11 + { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12 + { "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement + -- OakCongratulatesPlayerScript: rival faces left, Oak faces down + { "face_object", 1, "left" }, -- 14 + { "face_object", 2, "down" }, -- 15 + { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16 + -- OakDisappointedWithRivalScript: Oak turns to the rival (right) + { "face_object", 2, "right" }, -- 17 + { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18 + -- OakComeWithMeScript: Oak faces down again, then exits up + { "face_object", 2, "down" }, -- 19 + { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20 + { "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement + { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22 + -- hand the induction off to the HALL_OF_FAME room (consumed by its + -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) + { "set_field", "pendingHallOfFame", true }, -- 23 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24 +} + M.CHAMPIONS_ROOM = { + onEnter = function(game, ow) + if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end + -- Lance entrance warps land at y=7; HoF return warps land at y=0. + -- Only the south entry should run ChampionsRoomPlayerEntersScript. + if ow.player.cellY < 7 then return end + local rival + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then + rival = npc + break + end + end + -- RivalEntrance_RLEMovement, then the battle/Oak script (queued + -- separately so talk-script jump indices stay 1-based as written). + ow:queueScript({ + { "move_player", "up", 1 }, + { "move_player", "right", 1 }, + { "move_player", "up", 3 }, + }) + ow:queueScript(championsRoomRivalScript, { npc = rival }) + end, talk = { - TEXT_CHAMPIONSROOM_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 - { "jump_if_true", 24 }, -- 3 - { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 - { "rival_battle", "OPP_RIVAL3", 1 }, -- 5 - { "jump_if_false", 24 }, -- 6 - { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 - { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 - -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, - -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = - -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText - -- is the port's generic " defeated BLUE!" engine line instead). - { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 - -- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in - { "show_text", "_ChampionsRoomOakText" }, -- 10 - { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11 - { "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2) - -- OakCongratulatesPlayerScript: rival faces left, Oak faces down - { "face_object", 1, "left" }, -- 13 - { "face_object", 2, "down" }, -- 14 - { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15 - -- OakDisappointedWithRivalScript: Oak turns to the rival (right) - { "face_object", 2, "right" }, -- 16 - { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17 - -- OakComeWithMeScript: Oak faces down again, then exits up - { "face_object", 2, "down" }, -- 18 - { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19 - { "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0) - { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21 - -- hand the induction off to the HALL_OF_FAME room (consumed by its - -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) - { "set_field", "pendingHallOfFame", true }, -- 22 - { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23 - }, + TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript, }, } diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index bc77679d..e542c48b 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -412,14 +412,23 @@ M.GAME_CORNER = { -- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after) onEnter = function(game, ow) local poster = game.data.field.gameCornerPoster - if not poster then return end - local block = game.save.flags[poster.event] and poster.openBlock - or poster.closedBlock - ow:replaceBlock(poster.x, poster.y, block) + if poster then + local block = game.save.flags[poster.event] and poster.openBlock + or poster.closedBlock + ow:replaceBlock(poster.x, poster.y, block) + end -- pick this visit's lucky slot machine -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm) local seats = game.data.field.slotMachines.GAME_CORNER ow.luckySlot = love.math.random(1, #seats) + -- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but + -- never hid him; clear the tile if he is already marked defeated + local rocketId = "GAME_CORNER_obj_11" + if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then + local Commands = require("src.script.Commands") + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + "GAME_CORNER", "GAMECORNER_ROCKET") + end end, talk = { -- the poster bg event: pressing A reveals the hidden switch @@ -448,7 +457,16 @@ M.GAME_CORNER = { -- losing he warns the BOSS and leaves the floor for good, freeing -- the tile in front of the hideout switch TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done) + local Commands = require("src.script.Commands") + local function hideRocket() + Commands.hide_object({ game = game, save = game.save, + overworld = ow }, + "GAME_CORNER", "GAMECORNER_ROCKET") + end + -- already beaten: hide anyway so pre-#50 saves that only have + -- defeatedTrainers (no objectToggles hide) clear the poster tile if ow:trainerDefeated(npc) then + hideRocket() done() return end @@ -462,10 +480,7 @@ M.GAME_CORNER = { game.data.text._GameCornerRocketAfterBattleText or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", function() - local Commands = require("src.script.Commands") - Commands.hide_object({ game = game, save = game.save, - overworld = ow }, - "GAME_CORNER", "GAMECORNER_ROCKET") + hideRocket() done() end)) end) diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 3be2bce4..30b2827e 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -645,12 +645,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag) } end +-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then +-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a +-- win. engageTrainer only shows the won line, so wrap talk like Lance / +-- Game Corner Rocket and push header.after immediately after a win. +local function e4LeaderTalk(afterLabel) + return function(game, ow, npc, done) + done = done or function() end + if ow:trainerDefeated(npc) then + local after = text(game)[afterLabel] + if after then push(game, after, done) else done() end + return + end + ow:engageTrainer(npc, function() + if not ow:trainerDefeated(npc) then + done() + return + end + local after = text(game)[afterLabel] + if after then push(game, after, done) else done() end + end) + end +end + M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05, "_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM") +M.LORELEIS_ROOM.talk = { + TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"), +} M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, "_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM") +M.BRUNOS_ROOM.talk = { + TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"), +} M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, "_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") +M.AGATHAS_ROOM.talk = { + TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"), +} -- ------------------------------------------------------------------- -- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4 @@ -672,13 +704,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, -- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals -- the door behind the player with SFX_GO_INSIDE -- (24,16) the entrance staircase -> WalkToLance: an auto-walk --- (up 12, left 12, down 7, left 6) landing on (6,11). --- It marches straight across the room's water decor: --- pokered's CollisionCheckOnLand skips collision --- entirely while simulated joypad states run, and our --- scriptMove is collision-free the same way. +-- landing on (6,11). Vanilla WalkToLance_RLEList is +-- up 12 / left 12 / down 7 / left 6 and skips collision +-- through void tiles; our camera follows that literally +-- and briefly shows the empty upper chamber (reads as +-- "Gary's room"). We keep the same landing cell but +-- route along the open-door floor corridor instead. -- ------------------------------------------------------------------- +-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR +-- unset (entrance blocks $31/$32). Exposed for parity tests. +local LANCE_WALK_IN = { + { "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 }, + { "up", 9 }, { "left", 2 }, { "up", 3 }, +} + local function lanceEntranceBlocks(game, ow) local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR ow:replaceBlock(2, 6, locked and 0x72 or 0x31) @@ -693,21 +733,24 @@ local function lanceLockDoor(game, ow) end local function lanceWalkIn(game, ow) - ow:scriptMove(ow.player, "up", 12, function() - ow:scriptMove(ow.player, "left", 12, function() - ow:scriptMove(ow.player, "down", 7, function() - ow:scriptMove(ow.player, "left", 6, function() - -- the walk lands on (6,11); vanilla's per-frame coord poll - -- then locks the door at once. scriptMove landings do not - -- fire onStep, so lock here. - lanceLockDoor(game, ow) - end) - end) - end) - end) + local i = 0 + local function step() + i = i + 1 + local seg = LANCE_WALK_IN[i] + if not seg then + -- lands on (6,11); vanilla's per-frame coord poll then locks the + -- door at once. scriptMove landings do not fire onStep, so lock + -- here. + lanceLockDoor(game, ow) + return + end + ow:scriptMove(ow.player, seg[1], seg[2], step) + end + step() end M.LANCES_ROOM = { + walkInRoute = LANCE_WALK_IN, onEnter = function(game, ow) lanceEntranceBlocks(game, ow) -- the warp arrival lands ON the staircase trigger, and onStep only @@ -727,7 +770,14 @@ M.LANCES_ROOM = { end if not lance or ow:trainerDefeated(lance) then return false end lance:facePlayer(ow.player) - ow:engageTrainer(lance, function() end) + -- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer + -- after-battle text (rival became champion first). engageTrainer + -- only shows the won line, so push the after text on a win. + ow:engageTrainer(lance, function() + if not ow:trainerDefeated(lance) then return end + local after = text(game)._LancesRoomLanceAfterBattleText + if after then push(game, after) end + end) return true end if (x == 5 or x == 6) and y == 11 then diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 87edf7e8..0d7dfa26 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -52,6 +52,10 @@ local imageCache = {} -- fully transparent rows below a pic's content (the extracted 32x32 back -- pics carry baked-in padding); used to sit the pic flush on the text box local imagePadBottom = {} +-- fully transparent columns left of a pic's content; at 2x (back pics) +-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware, +-- where those columns were white-on-white rather than shifted content +local imagePadLeft = {} -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = {} @@ -63,7 +67,7 @@ local function getImage(path, pal, trueColor) if trueColor then pal = nil end local key = pal and (path .. "#" .. pal.name) or path if not imageCache[key] then - local img, pad = nil, 0 + local img, pad, padL = nil, 0, 0 if love.image and love.image.newImageData then local id = Assets.imageData(path) if pal then @@ -86,13 +90,25 @@ local function getImage(path, pal, trueColor) if opaque then break end bottom = bottom - 1 end + local left = 0 + while left < w do + local opaque = false + for y = 0, h - 1 do + local _, _, _, a = id:getPixel(left, y) + if a > 0 then opaque = true break end + end + if opaque then break end + left = left + 1 + end img = love.graphics.newImage(id) pad = h - 1 - bottom + padL = left else img = Assets.image(path) -- headless stub: no pixel access end imageCache[key] = img imagePadBottom[img] = pad + imagePadLeft[img] = padL imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } end return imageCache[key] @@ -101,7 +117,7 @@ end -- hot reload: the next getImage re-resolves every pic through the asset -- search path and re-measures its ground padding function BattleState.invalidate() - imageCache, imagePadBottom, imageMeta = {}, {}, {} + imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {} end Assets.register(BattleState.invalidate) @@ -258,6 +274,10 @@ local function makeBattler(data, mon, isPlayer, save) } end +-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild +-- and trainer constructors pass the live save for the player side. +BattleState.makeBattler = makeBattler + -- The battle pic for `species` on the given side (back pic for the -- player side, front pic for the enemy side), tinted PAL_GRAYMON -- -- the same path makeBattler uses, but forced gray -- since this is only @@ -832,7 +852,7 @@ function BattleState:computeMusicKind() return "final" elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then return "gym" - elseif self.kind == "trainer" then + elseif self.kind == "trainer" or self.kind == "link" then return "trainer" end return "wild" @@ -894,6 +914,17 @@ function BattleState:enter() self.showEnemyTrainer = false self:startGrowIn(self.enemy) end) + elseif self.kind == "link" then + -- Colosseum has no foe trainer pic, but the enemy mon still grows + -- out of the ball after "X sent out Y!" (not the wild "already there" + -- intro that LinkBattle previously inherited from newWild). + self.enemySendingOut = true + self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE", + self.enemy.name)) + self:act(function() + self.enemySendingOut = false + self:startGrowIn(self.enemy) + end) end if not self.ghost then -- the enemy's cry plays as it appears (data/pokemon/cries.asm) @@ -950,20 +981,37 @@ function BattleState:exit() end end --- An action the battler is locked into (bypasses the menu), or nil. -function BattleState:lockedAction(battler) +-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the +-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the +-- player's (core.asm:1314-1315). Any switch frees the other side. +local function clearTrapping(battler) + if not battler then return end + battler.trappingTurns = nil + battler.trapMove = nil + battler.trapDamage = nil +end + +-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310): +-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT +-- skip the menu -- the player can still item/switch (and must press +-- FIGHT to continue a trapping sequence). +function BattleState:menuLockedAction(battler) if battler.mustRecharge then return { special = "recharge" } end if battler.charging then return battler.charging end if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end + if battler.rageMove then return battler.rageMove end + return nil +end + +-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own +-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff). +function BattleState:fightLockedAction(battler) if battler.trappingTurns and battler.trappingTurns > 0 then return { special = "trapping" } end if battler.bideTurns then return { special = "bide" } end - if battler.rageMove then return battler.rageMove end - -- held in place while the OPPONENT's trapping move is running - -- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a - -- trap ended early by paralysis/faint frees the victim immediately); - -- boundTurns is a mirror kept for Status.beforeMove's held check + -- held while the OPPONENT's trapping bit is set (live mirror so a + -- trap ended early by paralysis/faint frees the victim immediately) local opp = battler.isPlayer and self.enemy or self.player battler.boundTurns = opp and opp.trappingTurns and math.max(1, opp.trappingTurns) or nil @@ -973,6 +1021,11 @@ function BattleState:lockedAction(battler) return nil end +-- Full lock for AI / callers that need any forced action. +function BattleState:lockedAction(battler) + return self:menuLockedAction(battler) or self:fightLockedAction(battler) +end + function BattleState:playerHasPP() for i, mv in ipairs(self.player.curMoves) do if mv.pp > 0 and self.player.disabledSlot ~= i then return true end @@ -1079,8 +1132,9 @@ function BattleState:update(dt) if not (self.player.mustRecharge or self.player.rageMove) then self.player.flinched, self.enemy.flinched = false, false end - -- locked multi-turn actions skip the menu entirely - local locked = self:lockedAction(self.player) + -- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping + -- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312) + local locked = self:menuLockedAction(self.player) if locked then self:resolveTurn(locked) return @@ -1104,6 +1158,13 @@ function BattleState:update(dt) end) self:act(function() self:endOfTurn() end) elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return + end if not self:playerHasPP() then -- _NoMovesLeftText, then Struggle engages self:say(("%s has no\nmoves left!"):format(self.player.name)) @@ -1514,6 +1575,9 @@ function BattleState:resolveSwitch(newMon) self:restoreMimicked(self.player) -- the battle copy leaves with it local previous = self.player self.player = makeBattler(self.data, newMon, true, self.game.save) + -- SendOutMon (core.asm:1761-1762): player's send-out clears the + -- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch + clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[1], battler = self.player, @@ -1619,10 +1683,19 @@ end -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment -- and peel it back on miss/fail paths. +-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the +-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC. +-- Cancelling that row on miss/immune would otherwise leave the digger +-- invisible until another anim's resetPicFx (#100). function BattleState:cancelMoveAnim() local row = self.moveAnimRow if not row then return end self.moveAnimRow = nil + if row.anim == "DIG" or row.anim == "FLY" then + local user = row.attackerIsPlayer and self.player or self.enemy + local pf = user and self.picFx and self.picFx[user] + if pf then pf.hidden = nil end + end for i, item in ipairs(self.queue) do if item == row then table.remove(self.queue, i) @@ -1686,14 +1759,23 @@ end -- transient pic effects reset when a new animation row starts (each -- PlayAnimation redraws from a clean slate); `minimized` survives -- --- the minimize sprite replaces the pic DATA, so redraws keep it until --- the pic is reloaded (switch/Transform/ChangeMonPic) +-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's +-- charge hide is a cleared tilemap that must survive until SE_SHOW_* / +-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release): +-- clearing it here made Dig pop in before emerge and wrap/bounce (#100). +-- Other hides (Acid Armor, etc.) still clear so the next anim restores. function BattleState:resetPicFx() if not self.picFx then return end - for _, pf in pairs(self.picFx) do + local digFly = self.animName == "DIG" or self.animName == "FLY" + local digFlyUser = digFly and (self.animAttackerIsPlayer + and self.player or self.enemy) or nil + for battler, pf in pairs(self.picFx) do pf.kind, pf.t = nil, nil pf.ox, pf.oy = 0, 0 - pf.hidden = nil + local keepHide = battler.invulnerable or battler == digFlyUser + if not keepHide then + pf.hidden = nil + end end end @@ -2094,6 +2176,8 @@ function BattleState:executeAction(user, target, action) local oldName = self.enemy.name self.enemyIndex = action.index self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap + clearTrapping(self.player) self:syncSides() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[2], battler = self.enemy, @@ -2144,6 +2228,43 @@ function BattleState:executeAction(user, target, action) self:performMove(user, target, action, false) end +-- Sleep / confusion onomatopoeia from Check*StatusConditions +-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia +-- move rows. Player sleep plays the anim before FastAsleepText; +-- enemy sleep and both confusion sides print the text first. +function BattleState:statusOnomatopoeia(user, kind) + local isPlayer = user.isPlayer + local anim + if kind == "sleep" then + anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM" + else + anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM" + end + local text = kind == "sleep" + and (displayName(user) .. "\nis fast asleep!") + or (displayName(user) .. "\nis confused!") + if kind == "sleep" and isPlayer then + self:animNext(anim, isPlayer) + self:sayNext(text) + else + self:sayNext(text) + self:animNext(anim, isPlayer) + end +end + +-- Queue status text (+ sleep/confusion FX when the line matches). +-- Wake / snap-out / flinch / etc. stay text-only. +function BattleState:sayStatusMsg(user, msg) + local text = prefixEnemy(msg, user) + if msg:find("is fast asleep!", 1, true) then + self:statusOnomatopoeia(user, "sleep") + elseif msg:find("is confused!", 1, true) then + self:statusOnomatopoeia(user, "confused") + else + self:sayNext(text) + end +end + -- The pre-recharge slice of CheckPlayerStatusConditions (core.asm: -- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing -- the turn WITHOUT consuming the recharge flag. The disable/confusion/ @@ -2162,7 +2283,7 @@ function BattleState:preRechargeChecks(user, target) mon.status = nil self:sayNext(displayName(user) .. "\nwoke up!") else - self:sayNext(displayName(user) .. "\nis fast asleep!") + self:statusOnomatopoeia(user, "sleep") end return true end @@ -2189,7 +2310,7 @@ end -- returns true when the user's action is interrupted. function BattleState:statusInterrupt(user, target) local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) - for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end + for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a @@ -2266,11 +2387,16 @@ function BattleState:performMove(user, target, moveInst, isCalled) user.charging, user.chargeReady, user.invulnerable = nil, nil, nil end - -- PP: not for continuations, struggle, or called moves + -- PP: not for continuations, struggle, called moves, or (under + -- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever + -- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm). local isContinuation = releasing or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) or moveInst == user.rageMove - if not isContinuation and not moveInst.struggle and not isCalled then + local enemyUnlimited = not user.isPlayer + and self.ruleset and self.ruleset.enemyUnlimitedPP + if not isContinuation and not moveInst.struggle and not isCalled + and not enemyUnlimited then moveInst.pp = math.max(0, moveInst.pp - 1) end @@ -2574,8 +2700,20 @@ function BattleState:enemyMonFainted() self.participants = {} if self.kind == "trainer" then - if self.enemyIndex < #self.enemyParty then - self.enemyIndex = self.enemyIndex + 1 + -- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan + -- the whole enemy party for the first mon with HP left. Blindly + -- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later + -- slot can already be fainted, so the empty-HP mon comes out, the + -- FIGHT menu returns, and executeAction no-ops on target.hp <= 0. + local nextIndex + for i, mon in ipairs(self.enemyParty) do + if mon.hp > 0 then + nextIndex = i + break + end + end + if nextIndex then + self.enemyIndex = nextIndex -- SHIFT battle style (the default): announce the next mon and -- offer a free switch (SET skips the prompt) local nextMon = self.enemyParty[self.enemyIndex] @@ -2599,6 +2737,7 @@ function BattleState:enemyMonFainted() if mon ~= self.player.mon and mon.hp > 0 then local previous = self.player self.player = makeBattler(self.data, mon, true, game.save) + clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[1], @@ -2624,6 +2763,8 @@ function BattleState:enemyMonFainted() self:act(function() local previous = self.enemy self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap + clearTrapping(self.player) self:syncSides() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[2], battler = self.enemy, @@ -2777,6 +2918,7 @@ function BattleState:openReplacementMenu() self:restoreMimicked(self.player) local previous = self.player self.player = makeBattler(self.data, mon, true, game.save) + clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[1], battler = self.player, @@ -3474,17 +3616,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale) love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) end elseif k == "slideUp" then - -- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames - local scroll = 8 * math.min(7, math.floor(t / 2) + 1) - local src = math.floor(scroll / scale) % h - if src == 0 then - love.graphics.draw(img, x + ox, y, 0, scale, scale) - else - local top = love.graphics.newQuad(0, src, w, h - src, w, h) - love.graphics.draw(img, top, x + ox, y, 0, scale, scale) - local bottom = love.graphics.newQuad(0, 0, w, src, w, h) - love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale, - 0, scale, scale) + -- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's + -- SLIDE_DOWN the tilemap is blank; each step fills the next bottom + -- row so the mon emerges from underground. A cyclic wrap of a full + -- pic looked like a bounce at Dig's end (#100). + local step = math.min(7, math.floor((t - 1) / 2) + 1) + local visible = math.floor(h * step / 7) + if visible > 0 then + local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h) + love.graphics.draw(img, quad, x + ox, + y + (h - visible) * scale, 0, scale, scale) end elseif xscale < 1 then -- AnimationSquishMonPic: columns collapse toward the middle @@ -3696,6 +3837,20 @@ function BattleState:drawAnimLayer(colorized) end end +-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in +-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that +-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles; +-- vertical pad is (7-h) -- bottom-aligned inside the 7x7. +local function enemyPicXY(img, slide, sx, sy) + local tw = math.floor(img:getWidth() / 8) + local th = math.floor(img:getHeight() / 8) + if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end + if th < 1 then th = 1 elseif th > 7 then th = 7 end + local hPad = math.floor((8 - tw) / 2) + local vPad = 7 - th + return 96 + 8 * hPad - slide + sx, 8 * vPad + sy +end + -- the two mon pics (or the trainer/back pics), offset by the window -- shake -- on the GB the pics are BG tiles, so they move with it function BattleState:drawPicsLayer(slide, sx, sy) @@ -3713,19 +3868,18 @@ function BattleState:drawPicsLayer(slide, sx, sy) g.intersectScissor(0, 0, 160, clipY) clipped = true end - -- Enemy: front sprite top-right (GB: pic at hlcoord 12,0). + -- Enemy: front sprite in the 7x7 slot at hlcoord 12,0. if self.showEnemyTrainer and self.trainerPic then -- the enemy trainer pic holds the mon slot until the send-out local img = self:picImage(self.trainerPic) love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx, - math.max(0, 48 - img:getHeight()) + sy) + local ex, ey = enemyPicXY(img, slide, sx, sy) + love.graphics.draw(img, ex, ey) elseif self.enemy and self.enemy.sprite and not self.enemyHidden and not self.enemySendingOut and not self:fxHidden(self.enemy) then local img = self:picImage(self.enemy.sprite) love.graphics.setColor(1, 1, 1, 1) - local ex = 160 - 8 - img:getWidth() - slide + sx - local ey = math.max(0, 48 - img:getHeight()) + sy + local ex, ey = enemyPicXY(img, slide, sx, sy) local gs = self:growInScale(self.enemy) if gs then -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge @@ -3739,15 +3893,18 @@ function BattleState:drawPicsLayer(slide, sx, sy) end end - -- Player: back sprite bottom-left (2x like the GB, feet near y=100). + -- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96. + -- Left transparent columns (matted white) are pulled back so opaque + -- pixels land where hardware's white-on-white columns left them. local hidePlayer = self.safari or self.demo if self.showPlayerBack and self.playerBackPic then -- Red's (or the old man's) back pic until "Go!"; it stays up for -- the whole safari / catch-demo battle like the original local img = self:picImage(self.playerBackPic) local pad = imagePadBottom[self.playerBackPic] or 0 + local padL = imagePadLeft[self.playerBackPic] or 0 love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, 16 + slide + sx, + love.graphics.draw(img, 8 - padL * 2 + slide + sx, 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) elseif self.player and self.player.sprite and not hidePlayer and not self.sendingOut and not self:fxHidden(self.player) then @@ -3755,17 +3912,19 @@ function BattleState:drawPicsLayer(slide, sx, sy) love.graphics.setColor(1, 1, 1, 1) -- feet flush on the text box top (y=96), ignoring baked-in padding local pad = imagePadBottom[self.player.sprite] or 0 + local padL = imagePadLeft[self.player.sprite] or 0 + local px = 8 - padL * 2 + sx local gs = self:growInScale(self.player) if gs then -- the player-side AnimateSendingOutMon grow (after the poof, - -- core.asm:1757-1762): feet pinned at y=96, center at x=16+w + -- core.asm:1757-1762): feet pinned at y=96, center at x=8+w if gs > 0 then - love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx, + love.graphics.draw(img, px + img:getWidth() * (1 - gs), 96 - (img:getHeight() - pad) * 2 * gs + sy, 0, 2 * gs, 2 * gs) end else - self:drawBattlerPic(self.player, 16 + sx, + self:drawBattlerPic(self.player, px, 96 - (img:getHeight() - pad) * 2 + sy, 2) end end @@ -3820,16 +3979,22 @@ function BattleState:drawHUDs(slide) love.graphics.setColor(0, 0, 0, 1) Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) end - -- trainer-battle party pokeball rows during the intro + -- trainer/link party pokeball rows during the intro -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) - if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack) - and slide == 0 then + local showIntroBalls = slide == 0 and ( + (self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)) + or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut)) + ) + if showIntroBalls then love.graphics.setColor(1, 1, 1, 1) - if self.showEnemyTrainer and self.enemyParty then + if self.enemyParty and ( + (self.kind == "trainer" and self.showEnemyTrainer) + or (self.kind == "link" and self.enemySendingOut) + ) then self:drawBallRow(self.enemyParty, 64, 16, -8) end if self.showPlayerBack then - self:drawBallRow(self.game.save.party, 88, 80, 8) + self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end end local hidePlayer = self.safari or self.demo @@ -3863,7 +4028,8 @@ function BattleState:drawTextArea() if self.phase == "messages" and self.current then local shown = 0 for li, codes in ipairs(self.lines) do - local y = 104 + li * 8 + -- battle text uses every other tile row (hlcoord *,14 / *,16) + local y = 112 + (li - 1) * 16 for i = 1, #codes do if shown >= self.charIndex then break end Font.drawCode(codes[i], 8 + (i - 1) * 8, y) diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 6cc6dc89..48f1efb9 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -15,7 +15,8 @@ -- (super-effective) or adds 1 (not-effective when a better move exists); -- the MINIMUM-scored move is chosen, ties broken uniformly among the -- tied minima (core.asm:2971-3002). A non-minimal move is never --- selectable. Respects PP, Disable and Transform/Mimic move overrides. +-- selectable. Respects Disable (and PP only when the ruleset depletes +-- enemy PP — Gen 1 AI never reads wEnemyMonPP). local TypeChart = require("src.battle.TypeChart") @@ -212,9 +213,13 @@ end function TrainerAI.chooseMove(battler, rng, battle) rng = rng or love.math.random + -- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when + -- every move slot is missing/disabled (core.asm:2957-2999). modern_clean + -- depletes enemy PP and falls back to Struggle when none remain. + local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP local usable = {} for i, mv in ipairs(battler.curMoves) do - if mv.pp > 0 and battler.disabledSlot ~= i then + if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then table.insert(usable, mv) end end diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index ae560d5e..9b517c9d 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -13,4 +13,7 @@ return { randMax = 255, -- Focus Energy famously QUARTERS the crit rate instead of x4 focusEnergyBug = true, + -- Wild/trainer enemies never spend PP (DecrementPP only touches the + -- player side in pokered). They therefore never Struggle from empty PP. + enemyUnlimitedPP = true, } diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua index 9a8d747e..28a8c466 100644 --- a/src/battle/rulesets/modern_clean.lua +++ b/src/battle/rulesets/modern_clean.lua @@ -9,4 +9,6 @@ return { randMin = 217, randMax = 255, focusEnergyBug = false, + -- Gen 2+ style: AI opponents deplete PP and can Struggle when empty. + enemyUnlimitedPP = false, } diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 398270b3..39cd980f 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -27,4 +27,12 @@ function FixedStep:update(dt) end end +-- Drop any pending catch-up steps. A hitch inside one logic step (map +-- seam setMap / song start) makes the next real-time dt huge; without this +-- the while-loop above would advance many walk frames before the next +-- draw, which looks like a slide with no leg animation (issue #93). +function FixedStep:discardCatchup() + self.accum = 0 +end + return FixedStep diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 47b0dd99..9d810c79 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -478,6 +478,25 @@ SaveData.addCoreMigration(2, function(save) end end) +-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the +-- floor after defeat (defeatedTrainers only). The #50 script now hides +-- him via objectToggles, but saves that already beat him never got the +-- toggle -- he still blocks the hideout switch. from=3 so every pre-4 +-- save reconciles once, then is skipped after re-stamp. +SaveData.addCoreMigration(3, function(save) + local defeated = save.defeatedTrainers + if not defeated or not defeated["GAME_CORNER_obj_11"] then return end + save.objectToggles = save.objectToggles or {} + local mapToggles = save.objectToggles.GAME_CORNER + if not mapToggles then + mapToggles = {} + save.objectToggles.GAME_CORNER = mapToggles + end + if mapToggles.GAMECORNER_ROCKET ~= false then + mapToggles.GAMECORNER_ROCKET = false + end +end) + -- ------- write -- Game progress only; options are written separately via saveOptions. diff --git a/src/core/Version.lua b/src/core/Version.lua index 0465b82a..914fe00b 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -7,7 +7,7 @@ local Version = { engine = "1.0.0", -- game/engine release (semver triple) modApi = 2, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) - saveFormat = 3, -- save.meta.format + saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 2826c2f8..dce7f012 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -36,18 +36,13 @@ local function makeRng(seed) end end --- battler builder shared by both sides -- NO badge boosts, so both --- machines compute identical stats +-- Battlers go through BattleState.makeBattler so pics get the same +-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer +-- battles. save=nil skips badge boosts so both machines keep identical +-- stats (Gen 1 cable battles famously kept badges; we still diverge). local function mkBattler(data, mon, isPlayer) - local def = data.pokemon[mon.species] - local ok, img = pcall(love.graphics.newImage, - isPlayer and def.spriteBack or def.spriteFront) - return { - mon = mon, def = def, isPlayer = isPlayer, stages = {}, - name = mon.nickname or def.name, - curStats = mon.stats, curTypes = def.types, curMoves = mon.moves, - sprite = ok and img or nil, - } + local BattleState = require("src.battle.BattleState") + return BattleState.makeBattler(data, mon, isPlayer, nil) end -- canonical (host-side-first) state hash, unchanged since v1: it stays on @@ -213,6 +208,8 @@ function LinkBattle.new(game, net, opts) self.player = mkBattler(game.data, myParty[1], true) self.enemy = mkBattler(game.data, theirParty[1], false) self.enemyParty = theirParty + self.playerParty = myParty -- intro ball row uses the clamped copies + self.opponentName = theirName self.introText = ("%s wants\nto battle!"):format(theirName) self.remoteHashes = {} self.localHashes = {} @@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts) return nil end + -- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState + local function sendOutPlayer(s, mon) + local previous = s.player + s.player = mkBattler(game.data, mon, true) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[1], battler = s.player, previous = previous, + }) + s.sendingOut = true + s:sayNext(s:sendOutText(s.player.name)) + s:animNext("POOF_ANIM", false) + s:actNext(function() + s.sendingOut = false + s:startGrowIn(s.player) + require("src.core.Sound").playCry(s.data, s.player.mon.species) + end) + end + + -- enemy-side EnemySendOut (grow-in + cry; no poof) + local function sendOutEnemy(s, mon) + local previous = s.enemy + s.enemy = mkBattler(game.data, mon, false) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[2], battler = s.enemy, previous = previous, + }) + s.enemySendingOut = true + s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) + s:actNext(function() + s.enemySendingOut = false + s:startGrowIn(s.enemy) + s:actNext(function() + require("src.core.Sound").playCry(s.data, s.enemy.mon.species) + end) + end) + end + -- decode a remote action message against the enemy battler local function decodeTheirAction(s, msg) if msg.kind == "move" then @@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts) -- switches happen before attacks (both may switch) if myMsg.kind == "switch" then local idx = myMsg.index - s:act(function() - s.player = mkBattler(game.data, myParty[idx], true) - s:sayNext(("Go! %s!"):format(s.player.name)) - end) + s:act(function() sendOutPlayer(s, myParty[idx]) end) myAction = nil end if theirSwitch then - s:act(function() - s.enemy = mkBattler(game.data, theirParty[theirSwitch], false) - s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) - end) + s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end) end s:act(function() @@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts) self.playerMonFainted = function(s) for _, mon in ipairs(myParty) do if mon.hp > 0 then - s:act(function() - s.player = mkBattler(game.data, mon, true) - s:sayNext(("Go! %s!"):format(s.player.name)) - end) + s:act(function() sendOutPlayer(s, mon) end) return end end @@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts) self.enemyMonFainted = function(s) for _, mon in ipairs(theirParty) do if mon.hp > 0 then - s:act(function() - s.enemy = mkBattler(game.data, mon, false) - s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) - end) + s:act(function() sendOutEnemy(s, mon) end) return end end diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 53cb81f5..6a438613 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -18,6 +18,12 @@ ManagerState.isOpaque = true -- Game:keypressed recognizes a directly-pushed instance ManagerState.screenId = "ManagerState" +-- Same as OptionsMenu: without this, title LOGO zones leak through when +-- MODS is opened from the title-screen options path. +function ManagerState:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end + -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 24618c9a..470adb42 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -23,6 +23,29 @@ Renderer.HEIGHT = 144 -- endFrame composites the padded canvas back with a matching offset. Renderer.UPRIGHT_MARGIN = 160 +-- LOVE units + framebuffer pixels + the live unit→pixel ratio. +-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often +-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB +-- pixel to a fractional number of framebuffer pixels → shimmer, uneven / +-- non-square "pixels", and movement judder (issue #87). Always derive the +-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi) +-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged. +local function displayMetrics() + local ww, wh = love.graphics.getDimensions() + local pw, ph = ww, wh + if love.graphics.getPixelDimensions then + pw, ph = love.graphics.getPixelDimensions() + end + local dpi = 1 + if ww > 0 and pw > 0 then + dpi = pw / ww + elseif love.graphics.getDPIScale then + dpi = love.graphics.getDPIScale() + end + if not dpi or dpi < 1e-6 then dpi = 1 end + return ww, wh, pw, ph, dpi +end + function Renderer:init() self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) self.canvas:setFilter("nearest", "nearest") @@ -36,10 +59,12 @@ function Renderer:init() self.uprightActive = false end --- integer scale that fits the GB UI viewport in the window +-- Integer framebuffer pixels per GB pixel that fit the window. Zoom / +-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE +-- units via / dpi when drawing. function Renderer:fitScale() - local ww, wh = love.graphics.getDimensions() - return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT))) + local _, _, pw, ph = displayMetrics() + return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) end -- world-pass canvas size in world pixels: enough to fill the window at s'. @@ -48,9 +73,13 @@ end -- background peeking at the receded top/bottom corners; flat mode returns -- exactly today's size (growth factor is 1 when tilt is inactive). function Renderer:worldViewSize() - local ww, wh = love.graphics.getDimensions() - local s = Zoom.scale(self:fitScale()) + local ww, wh, _, _, dpi = displayMetrics() + local s = Zoom.scale(self:fitScale()) / dpi local vw, vh = Zoom.fillViewSize(s, ww, wh) + -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), + -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer. + if vw % 2 ~= 0 then vw = vw + 1 end + if vh % 2 ~= 0 then vh = vh + 1 end if Tilt.active() then local g = Tilt.viewGrowth() vw, vh = math.ceil(vw * g), math.ceil(vh * g) @@ -289,11 +318,14 @@ end -- presented through the GBC FX shader as a final pass. function Renderer:endFrame(zones, worldZones) love.graphics.setCanvas() - local ww, wh = love.graphics.getDimensions() - local S = self:fitScale() + local ww, wh, pw, ph, dpi = displayMetrics() + -- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale. + local Sp = self:fitScale() + local S = Sp / dpi local vpw, vph = self.WIDTH * S, self.HEIGHT * S - local ox = math.floor((ww - vpw) / 2) - local oy = math.floor((wh - vph) / 2) + -- Snap the letterbox origin to a framebuffer pixel, then convert to units. + local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi + local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -321,9 +353,9 @@ function Renderer:endFrame(zones, worldZones) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) - -- blit `canvas` at integer `scale` into origin (bx, by), scissored to + -- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to -- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone - -- coords (canvas-space) into screen pixels. + -- coords (canvas-space) into screen units. local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil if not shader then @@ -355,11 +387,12 @@ function Renderer:endFrame(zones, worldZones) end if self.worldActive then - local s = Zoom.scale(S) + local sp = Zoom.scale(Sp) + local s = sp / dpi local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() - local wox = math.floor((ww - wvw * s) / 2) - local woy = math.floor((wh - wvh * s) / 2) + local wox = math.floor((pw - wvw * sp) / 2) / dpi + local woy = math.floor((ph - wvh * sp) / 2) / dpi -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or @@ -433,7 +466,8 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - GBCFX.present(present, S) + -- shader grid/shadow math is in framebuffer pixels + GBCFX.present(present, Sp) end self.worldActive = false self.uprightActive = false diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 96ed0ebf..9b23ef81 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -484,11 +484,51 @@ function Commands.heal_party(ctx) end end +-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen. +-- AddPartyMon only offers this for party mons (box deposits skip it). +-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the +-- yes/no result (scripts jump_if_false on give failure afterwards). +local function askNickname(ctx, mon) + local runner = ctx.runner + if not runner then return end + local success = ctx.lastCheck + local name = ctx.game.stringBuffer + or (ctx.game.data.pokemon[mon.species] + and ctx.game.data.pokemon[mon.species].name) + or mon.species + -- Prefer the real text label; fall back to the BattleState wording. + if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then + Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) + else + Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name)) + end + local ChoiceBox = require("src.ui.ChoiceBox") + ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) + if not yes then + ctx.lastCheck = success + runner:resume() + return + end + Screens.push(ctx.game, "NamingScreen", { + title = "NICKNAME?", maxLen = 10, + onDone = function(nick) + if nick and #nick > 0 then mon.nickname = nick end + ctx.lastCheck = success + runner:resume() + end, + }) + end)) + runner:yield() + ctx.lastCheck = success +end + -- give_pokemon : _GivePokemon (engine/events/ -- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets -- the asm's carry: true when the mon was given, false when both the -- party and every box are full (that .boxFull path leaves the giver's -- script able to offer again later, e.g. the Celadon Eevee ball). +-- Party adds run AskName (AddPartyMon) when a script runner is present; +-- mods that pre-set gift.nickname skip the prompt. function Commands.give_pokemon(ctx, species, level) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: @@ -505,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level) ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.pendingPokemonName = species require("src.battle.BattleState").stampOT(ctx.save, mon) - if not Party.add(ctx.save.party, mon) then + local addedToParty = Party.add(ctx.save.party, mon) + if not addedToParty then if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then ctx.lastCheck = false return @@ -517,6 +558,11 @@ function Commands.give_pokemon(ctx, species, level) dex.owned[species] = true end ctx.lastCheck = true + -- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames, + -- and callback-style callers that have no script runner to yield on. + if addedToParty and not gift.nickname and ctx.runner then + askNickname(ctx, mon) + end end function Commands.give_money(ctx, amount) @@ -1066,7 +1112,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", end for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", - "old_man_demo", "static_battle", "rival_battle", "give_item", "wait", + "old_man_demo", "static_battle", "rival_battle", "give_item", + "give_pokemon", "wait", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", "emote", "fade", "pan_camera", "play_once" }) do local meta = Commands.meta[verb] or {} diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index cdbc0990..193170fb 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex) consume(game, id) require("src.core.Sound").play(game.data, "Teleport_Exit1") ow.player.surfing = false - ow:warpToHealPoint() + -- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path); + -- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim) + ow:warpToHealPoint(nil, { arrive = "teleport" }) else showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" }) diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index 40883f8e..c756c6ae 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -1,14 +1,16 @@ -- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party --- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's --- .ScrollPic), then its name/level shows and its cry plays --- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats --- shows the trainer name, play time, money and Prof. Oak's dex rating. --- Plays Music_HallOfFame when the audio data has it. Calls onDone() after --- popping itself. +-- member's front sprite scrolls onto the right side of the screen +-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the +-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom +-- "HALL OF FAME" text box before fading to the next mon. After the +-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the +-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame +-- when the audio data has it. Calls onDone() after popping itself. local Font = require("src.render.Font") local Music = require("src.core.Music") local Sound = require("src.core.Sound") +local TypeChart = require("src.battle.TypeChart") local HallOfFame = {} HallOfFame.__index = HallOfFame @@ -17,6 +19,10 @@ HallOfFame.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function HallOfFame:sgbPalettes(game) local P = require("src.render.PaletteFX") + if self.phase == "player" or self.phase == "player_stats" + or self.phase == "player_dex" or self.phase == "player_rating" then + return P.wholeNamed(game.data, "MEWMON") + end local mon = game.save.party[self.index or 0] if mon then local c = P.monPal(game.data, mon.species) @@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game) return P.wholeNamed(game.data, "MEWMON") end -local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early) - -- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per --- DelayFrame (doubled on SGB) until it settles. The back pic (an --- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left --- and off the left edge first; tracing the actual hSCX/hSCY math shows --- the real front pic that follows enters from the *left* edge and --- slides *right* into its resting tile, at that same 4px/frame rate -- --- that's the half we port here (the back-pic wipe is a VRAM/scroll- --- register trick with no equivalent in this sprite-based renderer). +-- DelayFrame (doubled on SGB) until it settles. The front pic rests at +-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs). local SCROLL_SPEED = 4 -- px/frame @ 60fps +local PIC_X, PIC_Y = 12 * 8, 5 * 8 + +-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom +-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite. +local INFO_HOLD = 80 +local HOF_HOLD = 180 +local FADE_FRAMES = 20 + +-- HoFPrintTextAndDelay after each dex line +local DEX_HOLD = 120 local function tryImage(path) if not path then return nil end @@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY) for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do if maxY and y > maxY then break end Font.draw(line, x, y) - y = y + 10 + y = y + 8 end return y end @@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone) self.timer = 0 self.phase = "mons" self.sprites = {} -- species -> image or false + self.playerPic = tryImage("assets/generated/trainer_card/red.png") + self.scrollX = PIC_X + self.showHofBanner = false + self.fade = 0 return self end @@ -84,16 +97,21 @@ end function HallOfFame:nextMon() self.index = self.index + 1 local mon = self.game.save.party[self.index] + self.showHofBanner = false + self.fade = 0 if mon then - self.timer = MON_FRAMES + self.phase = "mons" + self.timer = INFO_HOLD Sound.playCry(self.game.data, mon.species) - -- scroll the new inductee's pic in from the left (see SCROLL_SPEED) local sprite = self:spriteFor(mon.species) - local w = sprite and sprite:getWidth() or 0 - self.scrollRestX = math.floor((160 - w) / 2) + local w = sprite and sprite:getWidth() or 56 self.scrollX = -w else - self.phase = "congrats" + -- HoFShowMonOrPlayer with wHoFMonOrPlayer = player + self.phase = "player" + self.timer = 0 + local w = self.playerPic and self.playerPic:getWidth() or 56 + self.scrollX = -w end end @@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned() return seen, owned end -function HallOfFame:update(dt) - local input = self.game.input - if self.phase == "mons" then - if self.scrollX and self.scrollX < self.scrollRestX then - self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED) - end - self.timer = self.timer - 1 - if input:wasPressed("a") or self.timer <= 0 then - self:nextMon() - end - elseif input:wasPressed("a") then - Sound.play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end +function HallOfFame:advanceMonPhase() + if self.phase == "mons" and not self.showHofBanner then + -- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME" + self.showHofBanner = true + self.timer = HOF_HOLD + elseif self.phase == "mons" then + self.phase = "fade" + self.timer = FADE_FRAMES + self.fade = 0 + elseif self.phase == "fade" then + self:nextMon() end end -function HallOfFame:draw() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) - love.graphics.setColor(0, 0, 0, 1) - if self.phase == "mons" then - Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8) - local mon = self.game.save.party[self.index] - if mon then - local def = self.game.data.pokemon[mon.species] - love.graphics.setColor(1, 1, 1, 1) - local sprite = self:spriteFor(mon.species) - if sprite then - local w, h = sprite:getDimensions() - love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h) - end - love.graphics.setColor(0, 0, 0, 1) - local name = mon.nickname or (def and def.name) or mon.species - Font.draw(name, 32, 108) - Font.draw((":L%d"):format(mon.level), 112, 108) - end - else - -- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real - -- screen is a fresh ClearScreen): trainer name, play time, money, - -- then the POKéDEX seen/owned tally and Prof. Oak's rating text, - -- using the same real save-data fields as TrainerCard.lua - -- (save.player.name/playTime/money) and PokedexMenu.lua/ - -- OverworldController:dexRating (save.pokedex.seen/owned). - local save = self.game.save - local text = self.game.data.text or {} - local y = 8 - Font.draw(save.player.name or "RED", 8, y) - y = y + 16 - local t = math.floor(save.playTime or 0) - Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600), - math.floor(t / 60) % 60), 8, y) - y = y + 12 - Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y) - y = y + 16 +function HallOfFame:update(dt) + local input = self.game.input + local skip = input:wasPressed("a") + if self.phase == "mons" or self.phase == "fade" then + if self.phase == "mons" and self.scrollX < PIC_X then + self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED) + return + end + if self.phase == "fade" then + self.timer = self.timer - 1 + self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES + if self.timer <= 0 or skip then self:advanceMonPhase() end + return + end + self.timer = self.timer - 1 + if skip or self.timer <= 0 then + self:advanceMonPhase() + end + elseif self.phase == "player" then + if self.scrollX < PIC_X then + self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED) + return + end + self.phase = "player_stats" + self.timer = DEX_HOLD + elseif self.phase == "player_stats" then + -- name / play time / money boxes are up; then DexSeenOwnedText + self.timer = self.timer - 1 + if skip or self.timer <= 0 then + self.phase = "player_dex" + self.timer = DEX_HOLD + end + elseif self.phase == "player_dex" then + self.timer = self.timer - 1 + if skip or self.timer <= 0 then + self.phase = "player_rating" + self.timer = DEX_HOLD + end + elseif self.phase == "player_rating" then + self.timer = self.timer - 1 + if skip or self.timer <= 0 then + -- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here) + self.game.stack:pop() + if self.onDone then self.onDone() end + end + end +end + +-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels +function HallOfFame:drawMonInfo(mon) + local def = self.game.data.pokemon[mon.species] + Font.drawBox(0, 2, 12, 11) + love.graphics.setColor(0, 0, 0, 1) + local name = mon.nickname or (def and def.name) or mon.species + Font.draw(name, 1 * 8, 4 * 8) + Font.draw("LEVEL/", 2 * 8, 6 * 8) + Font.draw("TYPE1/", 2 * 8, 7 * 8) + local t1 = def and def.types and def.types[1] + local t2 = def and def.types and def.types[2] + local dual = t2 and t2 ~= t1 + if dual then + Font.draw("TYPE2/", 2 * 8, 8 * 8) + end + -- PrintLevelCommon at (8,7): bare level digits (no tile here) + Font.draw(tostring(mon.level), 8 * 8, 7 * 8) + -- PrintMonType at (3,9) / +2 rows for type 2 + if t1 then + Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8) + end + if dual then + Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8) + end +end + +-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14 +function HallOfFame:drawHofBanner() + Font.drawBox(2, 13, 16, 5) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("HALL OF FAME", 4 * 8, 15 * 8) +end + +function HallOfFame:drawPic(img) + if not img then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y) +end + +-- HoFDisplayPlayerStats boxes + labels (player pic already on the right) +function HallOfFame:drawPlayerStats() + local save = self.game.save + -- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4) + Font.drawBox(5, 0, 11, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(save.player.name or "RED", 7 * 8, 2 * 8) + + -- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8) + Font.drawBox(0, 4, 12, 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("PLAY TIME", 1 * 8, 6 * 8) + local t = math.floor(save.playTime or 0) + Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), + 5 * 8, 7 * 8) + Font.draw("MONEY", 1 * 8, 9 * 8) + -- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard + Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8) +end + +function HallOfFame:drawDexBox(kind) + local save = self.game.save + local text = self.game.data.text or {} + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if kind == "seen" then local seen, owned = self:dexSeenOwned() local seenOwned = text._DexSeenOwnedText or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" seenOwned = seenOwned :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) - y = drawTextBlock(seenOwned, 8, y) + 6 - + drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8) + else + local _, owned = self:dexSeenOwned() local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") - Font.draw(ratingHeader, 8, y) - y = y + 12 - + Font.draw(ratingHeader, 1 * 8, 14 * 8) local rating = text[dexRatingKey(owned)] or "Keep it up!" - drawTextBlock(rating, 8, y, 136) + drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8) end +end + +function HallOfFame:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + + if self.phase == "mons" or self.phase == "fade" then + local mon = self.game.save.party[self.index] + if mon then + self:drawPic(self:spriteFor(mon.species)) + if self.scrollX >= PIC_X then + self:drawMonInfo(mon) + if self.showHofBanner then + self:drawHofBanner() + end + end + end + if self.phase == "fade" and self.fade > 0 then + love.graphics.setColor(1, 1, 1, self.fade) + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + elseif self.phase == "player" then + self:drawPic(self.playerPic) + elseif self.phase == "player_stats" then + self:drawPic(self.playerPic) + self:drawPlayerStats() + elseif self.phase == "player_dex" then + self:drawPic(self.playerPic) + self:drawPlayerStats() + self:drawDexBox("seen") + elseif self.phase == "player_rating" then + self:drawPic(self.playerPic) + self:drawPlayerStats() + self:drawDexBox("rating") + end + love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index feb578f7..027fa7e7 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -19,7 +19,16 @@ function Menu.new(game, items, opts) self.tx = opts.tx or 10 self.ty = opts.ty or 0 self.tw = opts.tw or 10 - self.th = opts.th or (#items * 2 + 2) + self.rowStep = opts.rowStep or 2 + -- maxVisible: cap the box to this many rows and scroll the rest instead + -- of growing past it (e.g. the start menu, whose row count varies with + -- save state and mod hooks); nil/unset keeps every caller's old + -- behavior of sizing the box to fit all items. + self.maxVisible = opts.maxVisible + self.scroll = 0 + local visible = (self.maxVisible and math.min(self.maxVisible, #items)) + or #items + self.th = opts.th or (visible * self.rowStep + 2) self.cancelable = opts.cancelable ~= false -- Whether START closes the menu. In pokered a menu responds only to the -- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the @@ -31,9 +40,25 @@ function Menu.new(game, items, opts) -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- menus silent (home/window.asm HandleMenuInput_) self.noSound = opts.noSound or false + self:clampScroll() return self end +-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window; +-- callers that move self.index directly (e.g. restoring a saved cursor +-- position) should call this afterwards to scroll it into view +function Menu:clampScroll() + if not (self.maxVisible and #self.items > self.maxVisible) then + self.scroll = 0 + return + end + if self.index - self.scroll > self.maxVisible then + self.scroll = self.index - self.maxVisible + elseif self.index - self.scroll < 1 then + self.scroll = self.index - 1 + end +end + function Menu:update(dt) local input = self.game.input if input:wasPressed("up") then @@ -61,15 +86,29 @@ function Menu:update(dt) self.game.stack:pop() if self.onCancel then self.onCancel() end end + self:clampScroll() end function Menu:draw() Font.drawBox(self.tx, self.ty, self.tw, self.th) love.graphics.setColor(0, 0, 0, 1) - for i, item in ipairs(self.items) do - Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8) + local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) + or #self.items + for row = 1, visible do + local item = self.items[self.scroll + row] + if not item then break end + Font.draw(item.label, (self.tx + 2) * 8, + (self.ty + row * self.rowStep - (self.rowStep - 1)) * 8) + end + local cursorRow = self.index - self.scroll + Font.drawCode(Theme.cursor, (self.tx + 1) * 8, + (self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8) + -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState + -- use, sat on the bottom border like TextBox's page-advance cursor + if self.maxVisible and self.scroll + self.maxVisible < #self.items then + Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8, + (self.ty + self.th - 2) * 8) end - Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 767df6ee..1fa6b396 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -22,6 +22,14 @@ local OptionsMenu = {} OptionsMenu.__index = OptionsMenu OptionsMenu.isOpaque = true +-- Opaque full-screen menu: own MEWMON so opening OPTION from the title +-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that +-- zone covers UI rows 8-9, which is the third options box label line +-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}). +function OptionsMenu:sgbPalettes(game) + return PaletteFX.wholeNamed(game.data, "MEWMON") +end + -- TextSpeedOptionData frame delays with the original labels local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } -- no-loader fallback for the ruleset row, same pair BattleState keeps diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 01be2e36..38310e99 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -294,7 +294,9 @@ function PartyMenu:update(dt) if ow and heal then self.game.stack:push(Transition.whiteFlash(self.game, nil, function() require("src.core.Sound").play(self.game.data, "Teleport_Exit1") - ow:warpToHealPoint() + -- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets + -- BIT_FLY_WARP); blackouts must not pass arrive="teleport" + ow:warpToHealPoint(nil, { arrive = "teleport" }) end)) end return diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 35eb5640..aa1cc93a 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -7,6 +7,7 @@ local Font = require("src.render.Font") local Logger = require("src.core.Logger") local Menu = require("src.ui.Menu") +local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -117,11 +118,20 @@ function StartMenu.new(game) -- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A -- (engine/menus/draw_start_menu.asm), so START closes it back to the -- overworld -- unlike most menus, whose masks omit PAD_START. + -- + -- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state, + -- and mods can append their own rows through the hook above, so the + -- double-spaced box (the original's style) can grow past the 18-tile + -- canvas. Cap it at however many rows actually fit and scroll the rest, + -- with Menu's moreArrow showing while there's more below. + local rowStep = 2 + local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) local menu = Menu.new(game, items, - { tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true }) + { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true }) -- the cursor position survives closing the menu -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) menu.index = math.min(game.save.startMenuIndex or 1, #items) + menu:clampScroll() local baseUpdate = menu.update menu.update = function(self, dt) baseUpdate(self, dt) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 20e1138d..bc637b45 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -13,13 +13,35 @@ TitleState.isOpaque = true -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- the version-ribbon band LOGO1, the rest MEWMON. +-- +-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those +-- LOGO bands. pokered's MainMenu clears the title and runs +-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the +-- title art visible underneath, so without an overlay those boxes inherit +-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the +-- overlay's DMG black unshaded while the logo and title mon keep title pals. +-- +-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON +-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM +-- pack (#128), so the version-ribbon row reads as a pink band. Force that +-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact. +local function withPureWhite(pal) + if not pal then return nil end + return { { 255, 255, 255 }, pal[2], pal[3], pal[4] } +end + function TitleState:sgbPalettes(game) local P = require("src.render.PaletteFX") local z = { P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), - P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9), + P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9), P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), } + local top = game.stack and game.stack:top() + local box = top and top.titleUiBox + if box then + z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4]) + end return z[3] and z or nil end @@ -119,8 +141,11 @@ local ContinueInfo = {} ContinueInfo.__index = ContinueInfo function ContinueInfo.new(title, save) - return setmetatable({ title = title, game = title.game, save = save }, - ContinueInfo) + -- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo + return setmetatable({ + title = title, game = title.game, save = save, + titleUiBox = { 4, 7, 19, 16 }, + }, ContinueInfo) end function ContinueInfo:update(dt) @@ -184,8 +209,11 @@ function TitleState:openMenu() love.event.quit() end end }) - game.stack:push(Menu.new(game, items, - { tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 })) + local th = #items * 2 + 2 + local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) + -- full-width title LOGO zones would recolor this box; see sgbPalettes + menu.titleUiBox = { 0, 0, 12, th - 1 } + game.stack:push(menu) end function TitleState:update(dt) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index e1b97338..4b1027b6 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -120,8 +120,11 @@ FieldDefaults.FIELD = { doorBlock = { bx = 2, by = 2, block = 5 } }, }, -- IsSurfingAllowed refuses SURF on the B4F stairs square until both - -- plug boulders are down (engine/overworld/field_move_messages.asm) + -- plug boulders are down (engine/overworld/field_move_messages.asm). + -- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire + -- without a held d-pad (scripts/SeafoamIslandsB3F.asm). seafoam = { + SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true }, SEAFOAM_ISLANDS_B4F = { surfBlocked = { { x = 7, y = 11, untilEvents = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 82c888f1..6daa739a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts) else self.player = Player.new(Game.data, x, y, facing) end + -- crossConnection re-arms this after setMap; clear so a warp/reload + -- cannot leave a stale deferred PlayMapMusic pending + self.pendingSeamMusic = nil self.entities = { self.player } for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end @@ -322,6 +325,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- 16/18 gate exits), and the scripted door-mat walkout that follows -- suppresses onStepComplete, so waiting for a plain step never mounts self:checkForcedMovement() + -- Seafoam B4F's map script pushes off the B3F stair warps every frame + -- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the + -- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts + -- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit + -- idle on those cells waiting for a player step. + self:checkSeafoamCurrent() -- snap the camera immediately: the overworld doesn't update while a -- Transition is on top, so a stale camera would show the new map at @@ -802,6 +811,15 @@ function OverworldState:update(dt) if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then self.warpEntryCell = nil end + -- deferred PlayMapMusic from crossConnection (issue #93) + if stepped and self.pendingSeamMusic then + local mapId = self.pendingSeamMusic + self.pendingSeamMusic = nil + if mapId == self.map.id then + require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, + self.player.surfing) + end + end if stepped and not scripted then self:onStepComplete() end @@ -1050,7 +1068,15 @@ function OverworldState:crossConnection(dir, conn) if not Map.defPassable(dest, ts, x, y, p.surfing) then return false end - self:setMap(conn.map, x, y, p.facing, { seamless = true }) + -- keepMusic: defer PlayMapMusic until the seam step lands. Starting a + -- new chip song inside setMap used to hitch the render thread (~200ms) + -- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth + -- removed most of that hitch; discarding catch-up + deferring the song + -- still protects the visible step when neighbor rebuild or the sync + -- fallback stalls, and avoids the rare one-frame volume spike from a + -- song swap mid-step. + self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true }) + self.pendingSeamMusic = conn.map -- place the player one cell before the seam (their old world spot, -- which the neighbor strip renders identically) and start the step -- into the new map RIGHT NOW so there is no one-frame stall at the @@ -1064,9 +1090,13 @@ function OverworldState:crossConnection(dir, conn) p.targetX, p.targetY = x, y p.moving = true p.progress = 0 + -- fresh walk-cycle clock so the seam step always shows leg frames + -- (mid-cycle stand phase would otherwise look like a slide) + p.animClock = 0 p.stepFramesCur = Game.save.onBike and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) or (FieldDefaults.world(Game.data, "stepFrames") or 16) + require("src.core.FixedStep"):discardCatchup() return true end @@ -2669,8 +2699,11 @@ function OverworldState:onStepComplete() elseif entry then -- still standing on the warp we arrived through; do not re-trigger it else + -- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise + -- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP + -- is set (Seafoam B3F currents — home/overworld.asm). local w = Warp.onArrive(self.map, p.cellX, p.cellY) - if not w and self:dirHeld() then + if not w and (self:dirHeld() or self.forcedWarp) then w = Warp.onCollision(self.map, Game.data.field.warpCarpets, p.cellX, p.cellY, p.facing) end @@ -2751,9 +2784,11 @@ function OverworldState:runSpinnerMoves(moves, i) local mv = moves[i] if not mv then self.player.spinning = false - if not self:checkSpinner() and self.player.surfing then - self:checkSeafoamCurrent() - end + -- Scripted steps skip onStepComplete while they run; once the RLE + -- finishes, re-enter the normal landing pipeline so chained spinners, + -- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP) + -- see the tile we stopped on — same as pokered after simulated joypad. + self:onStepComplete() return end self.player.spinning = true -- spin the sprite while sliding @@ -2926,6 +2961,9 @@ function OverworldState:checkSeafoamCurrent() if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then for _, c in ipairs(sf.forcedExit.coords) do if p.cellX == c.x and p.cellY == c.y then + -- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the + -- push so the B3F stair warps underfoot cannot bounce you back. + self.forcedWarp = false require("src.core.Sound").play(Game.data, "Collision") self:scriptMove(p, "up", c.y == 17 and 2 or 1) return true @@ -2947,6 +2985,12 @@ function OverworldState:checkSeafoamCurrent() end for _, c in ipairs(active) do if p.cellX == c.x and p.cellY == c.y then + -- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so + -- the south-edge water stairs auto-warp when the current ends. + if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id, + "setsForcedWarp") then + self.forcedWarp = true + end self:runSpinnerMoves(c.moves, 1) return true end @@ -3155,16 +3199,20 @@ end -- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT). -- The heal point is usually an interior, so LAST_MAP exits are re-pointed -- at its remembered town door rather than wherever the player left from. -function OverworldState:warpToHealPoint(onDone) +-- +-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim / +-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only +-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never +-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim. +function OverworldState:warpToHealPoint(onDone, opts) local heal = self:healPoint() self.player.surfing = false -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) Game.save.forcedBike = nil - -- rematerializing plays the teleport-in poof (EnterMapAnim in - -- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then - -- ENTER_2 after the spin-down); blackouts take this path too - self.arriveWarp = "teleport" + if opts and opts.arrive == "teleport" then + self.arriveWarp = "teleport" + end self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone) if heal.outdoor then self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) @@ -3202,9 +3250,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- one-step justWarped guard, which only skipped the very next frame's -- check and so let a mon walked back onto the pad re-trigger it. self.warpEntryCell = { x = x, y = y } - -- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player - -- back in (player_animations.asm EnterMapAnim); ordinary door - -- warps never take this branch + -- Fly/Teleport/Dig/Escape-Rope landings poof the player back in + -- (player_animations.asm EnterMapAnim). Blackouts and ordinary + -- door warps never take this branch. if arriveWarp == "fly" then require("src.core.Sound").play(Game.data, "Fly") elseif arriveWarp == "teleport" then diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 784ecd4a..80f5fc35 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -52,6 +52,9 @@ stub.graphics = { push = noop, pop = noop, translate = noop, scale = noop, rotate = noop, origin = noop, setShader = noop, setScissor = noop, getDimensions = function() return 640, 576 end, + -- dpi=1 desktop default; issue #87 tests override these for Android density + getPixelDimensions = function() return 640, 576 end, + getDPIScale = function() return 1 end, } stub.math = { diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 394a1c83..cb60985d 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -128,6 +128,8 @@ love.graphics = { translate = noop, scale = noop, rotate = noop, origin = noop, setScissor = noop, getColor = function() return 1, 1, 1, 1 end, getDimensions = function() return 640, 576 end, + getPixelDimensions = function() return 640, 576 end, + getDPIScale = function() return 1 end, } -- Fresh copies of the modules that cache a compiled shader or a page set diff --git a/tests/mod_save_tests.lua b/tests/mod_save_tests.lua index 9641c084..041047fb 100644 --- a/tests/mod_save_tests.lua +++ b/tests/mod_save_tests.lua @@ -215,6 +215,41 @@ do SaveData.runMigrations(tagged) check(tagged.version == "blue", "an existing version tag is left untouched") + -- #131: format-3 save that beat the Game Corner poster grunt before + -- hide_object was wired -- defeatedTrainers only, no objectToggles hide + local stuckGrunt = { + meta = { format = 3, mods = {} }, + defeatedTrainers = { GAME_CORNER_obj_11 = true }, + objectToggles = {}, + flags = {}, + player = {}, + } + SaveData.runMigrations(stuckGrunt) + check(stuckGrunt.objectToggles.GAME_CORNER + and stuckGrunt.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false, + "Game Corner rocket hide backfilled from defeatedTrainers") + check(stuckGrunt.meta.format == Version.saveFormat, + "format-3 hideout migration stamps to current") + -- already-hidden stays hidden; undefeated grunt is left alone + local alreadyHidden = { + meta = { format = 3, mods = {} }, + defeatedTrainers = { GAME_CORNER_obj_11 = true }, + objectToggles = { GAME_CORNER = { GAMECORNER_ROCKET = false } }, + player = {}, + } + SaveData.runMigrations(alreadyHidden) + check(alreadyHidden.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false, + "already-hidden rocket toggle is left false") + local neverFought = { + meta = { format = 3, mods = {} }, + defeatedTrainers = {}, + objectToggles = {}, + player = {}, + } + SaveData.runMigrations(neverFought) + check(not neverFought.objectToggles.GAME_CORNER, + "undefeated Game Corner rocket is not auto-hidden") + love.filesystem = realFS end diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index d994c6f5..7eb4f73b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -476,7 +476,9 @@ check(title.version and title.version.path "the shipped version ribbon loads") -- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon), --- not Red's, when the ROM pack carries Blue SuperPalettes +-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is +-- forced to pure white so ROM's {255,239,255} does not paint a pink band +-- against GBC-pack LOGO2/MEWMON whites. do local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") @@ -496,17 +498,109 @@ do PaletteFX.setMode("redpp") GameVersion.set("blue") local zones = TitleState.sgbPalettes(title, game) - check(zones and zones[2] and zones[2].colors == blueLogo1, - "Blue title ribbon zone uses ROM LOGO1 under RED++") + local ribbon = zones and zones[2] and zones[2].colors + check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239, + "Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++") + check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255 + and ribbon[1][3] == 255, + "Blue title ribbon white is pure (no pink SGB band)") GameVersion.set("red") zones = TitleState.sgbPalettes(title, game) local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1 - check(zones and zones[2] and zones[2].colors == gbcLogo1, - "Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's") + ribbon = zones and zones[2] and zones[2].colors + check(ribbon and ribbon[4][1] == gbcLogo1[4][1] + and ribbon[4][2] == gbcLogo1[4][2] + and ribbon[4][3] == gbcLogo1[4][3], + "Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's") GameVersion.set(prevVer) PaletteFX.setMode(prevMode) end +-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1 +-- (blue/red UI ink). A trailing trueColor zone covers the overlay box. +do + local logo2 = { + { 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 }, + } + local logo1 = { + { 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 }, + } + local mewmon = { + { 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 }, + } + local game = { + data = { palettes = { palettes = { + LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon, + } } }, + stack = newStack(), + } + local bare = TitleState.sgbPalettes(title, game) + check(bare and #bare == 3 and bare[1].colors == logo2, + "bare title keeps three LOGO/MEWMON zones") + check(not bare[4], "bare title has no overlay trueColor zone") + + game.stack:push(title) + -- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp + local Menu = require("src.ui.Menu") + local menu = Menu.new(game, { { label = "NEW GAME" } }, + { tx = 0, ty = 0, tw = 13, th = 4 }) + menu.titleUiBox = { 0, 0, 12, 3 } + game.stack:push(menu) + local withMenu = TitleState.sgbPalettes(title, game) + check(withMenu and #withMenu == 4 and withMenu[4].colors == false, + "title menu adds a trueColor overlay zone") + check(withMenu[4].x == 0 and withMenu[4].y == 0 + and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8, + "menu overlay covers the CONTINUE/NEW GAME box") + + game.stack:pop() + game.stack:push({ titleUiBox = { 4, 7, 19, 16 } }) + local withCont = TitleState.sgbPalettes(title, game) + check(withCont and #withCont == 4 and withCont[4].colors == false, + "continue-info overlay adds a trueColor zone") + check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8 + and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8, + "continue overlay matches DisplayContinueGameInfo's box") + + -- openMenu itself must stamp titleUiBox on the real Menu it pushes + while game.stack:top() do game.stack:pop() end + title.game = game + title:openMenu() + local opened = game.stack:top() + check(opened and opened.titleUiBox + and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12, + "openMenu stamps titleUiBox on the pushed Menu") +end + +-- Options / mod manager opened from the title must not inherit LOGO1 +-- (third options box = rows 8-9 would otherwise tint pink). +do + local OptionsMenu = require("src.ui.OptionsMenu") + local ManagerState = require("src.mods.ManagerState") + local PaletteFX = require("src.render.PaletteFX") + local mewmon = { + { 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 }, + } + local game = { data = { palettes = { palettes = { MEWMON = mewmon } } }, + save = { options = {} } } + local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game) + check(optZones and #optZones == 1 and optZones[1].colors == mewmon, + "OptionsMenu owns a whole-screen MEWMON zone") + local modZones = ManagerState.sgbPalettes(ManagerState, game) + check(modZones and #modZones == 1 and modZones[1].colors == mewmon, + "ManagerState owns a whole-screen MEWMON zone") + -- stack walk: with Options on top of Title, Game would pick Options + local titleZones = TitleState.sgbPalettes(title, { + data = { palettes = { palettes = { + LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } }, + LOGO2 = mewmon, MEWMON = mewmon, + } } }, + }) + check(titleZones and titleZones[2].colors[1][2] == 255, + "title LOGO1 sanitize still pure-white with pink ROM input") + check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus") +end + local OakSpeech = require("src.ui.OakSpeech") local ogame = { data = { field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } }, diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 070793df..3a0291a9 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -21,12 +21,17 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi local champ = init.get("CHAMPIONS_ROOM") check(champ ~= nil, "CHAMPIONS_ROOM map script registered") +check(champ and type(champ.onEnter) == "function", + "CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)") local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists") -- (2) the rival cutscene rows contain the pokered beats in order rows = rows or {} local preds = { + { "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)", + function(r) return r[1] == "play_music" and r[2] == "Music_Cities1" + and type(r[3]) == "table" and r[3].keep == true end }, { "show_object CHAMPIONSROOM_OAK", function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end }, { "move_npc(2,'up',5) OakEntranceAfterVictoryMovement", @@ -82,7 +87,13 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME") -- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and -- queues (does not directly run) the room cutscene. local queued -local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end } +local fakeOw = { + map = { def = { signs = {} }, widthCells = 10, signAt = {} }, + queueScript = function(self, script, extra) + queued = script + self.pendingScript = { script = script } + end, +} local fakeGame = { save = { pendingHallOfFame = true } } hof.onEnter(fakeGame, fakeOw) check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set") @@ -102,4 +113,41 @@ fakeGame.save.pendingHallOfFame = false hof.onEnter(fakeGame, fakeOw) check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed") +-- (6) Champions Room forced entrance (ChampionsRoomPlayerEntersScript): +-- from Lance (y=7), queue RivalEntrance walk then the rival battle script. +local champQueued = {} +local champOw = { + player = { cellX = 3, cellY = 7 }, + npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } }, + queueScript = function(_, script, extra) + champQueued[#champQueued + 1] = { script = script, extra = extra } + end, +} +local champGame = { save = { flags = {} } } +champ.onEnter(champGame, champOw) +eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script") +local walk = champQueued[1] and champQueued[1].script +check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1 + and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1 + and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3, + "entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)") +check(champQueued[2] and champQueued[2].script == rows, + "second queue is TEXT_CHAMPIONSROOM_RIVAL script rows") +check(champQueued[2] and champQueued[2].extra + and champQueued[2].extra.npc == champOw.npcs[1], + "rival script receives the CHAMPIONSROOM_RIVAL npc") + +-- already beaten this run: no forced entrance +champQueued = {} +champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = true +champ.onEnter(champGame, champOw) +eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter idle after champion beaten this run") + +-- Hall of Fame return (y=0) must not re-trigger even if the run flag is clear +champQueued = {} +champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil +champOw.player.cellY = 0 +champ.onEnter(champGame, champOw) +eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter ignores Hall of Fame return landing") + S.finish() diff --git a/tests/parity_F.lua b/tests/parity_F.lua index 7964565c..691375e5 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -7,6 +7,7 @@ -- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on -- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot -- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua). +-- Also #137: starter give_pokemon runs AskName (nickname yes/no). package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") @@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack") local SaveData = require("src.core.SaveData") local ScriptRunner = require("src.script.ScriptRunner") local Flags = require("src.script.Flags") +local ChoiceBox = require("src.ui.ChoiceBox") +local NamingScreen = require("src.ui.NamingScreen") local mapScripts = require("data.scripts.init") Game.data = Data @@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init() Game.save = SaveData.newGame() require("src.render.Font").load(Data) --- pumps a script coroutine to completion, mashing A through any --- show_text/ask boxes along the way (mirrors tests/run_tests.lua's --- runScript helper for the parcel/pokedex chain) -local function runScript(script) +-- pumps a script coroutine to completion; pressFn returns the Input.pressed +-- table for this frame (default: mash A through text/ask/naming) +local function runScript(script, pressFn) local r = ScriptRunner.new(Game, nil) r:run(script, {}) local guard = 0 - while r:isRunning() and guard < 2000 do + while r:isRunning() and guard < 4000 do guard = guard + 1 - Input.pressed = { a = true } + Input.pressed = pressFn and pressFn() or { a = true } StateStack:update(1 / 60) r:update() end @@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B "starter pick script completes") check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set") eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter") +check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR", + "starter joined the party") +-- A-mash accepts the nickname prompt and fills NamingScreen with A's +check(Game.save.party[1].nickname == "AAAAAAAAAA", + "starter nickname prompt accepted (AskName / #137)") -- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs === check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), @@ -107,4 +114,35 @@ check(repaired.OAKSLAB_POKEDEX1 == false and repaired.OAKSLAB_POKEDEX2 == false, "onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set") +-- === 6) #137: declining the starter nickname leaves no nickname === +-- Choice boxes in order: (1) "you want X?" YES, (2) nickname YES/NO -> NO +Game.save = SaveData.newGame() +Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB") +local choicesSeen, lastChoice = 0, nil +local function declineNickname() + local top = StateStack:top() + local mt = getmetatable(top) + if mt == ChoiceBox and top ~= lastChoice then + choicesSeen = choicesSeen + 1 + lastChoice = top + elseif mt ~= ChoiceBox then + lastChoice = nil + end + if mt == ChoiceBox and choicesSeen >= 2 then + return { b = true } + end + if mt == NamingScreen then + return { start = true } + end + return { a = true } +end +check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"), + declineNickname), + "starter pick with declined nickname completes") +check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "declined-nickname path still sets starter flag") +eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER", + "declined-nickname path still gives Charmander") +eq(Game.save.party[1] and Game.save.party[1].nickname, nil, + "declining nickname leaves the species name") + S.finish() diff --git a/tests/parity_H.lua b/tests/parity_H.lua index 1dadc4c6..2a0bda2c 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F", objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")), "SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state") +-- === issue #129: B3F current auto-warps via BIT_FORCED_WARP === +-- South-edge stairs at (20,17) are water warps (not door/warp tiles), so +-- CheckWarpsNoCollision needs BIT_FORCED_WARP (or a held d-pad). B3F +-- currents set that bit; after the RLE lands, the warp must fire without +-- further player input. +do + local FieldDefaults = require("src.world.FieldDefaults") + check(FieldDefaults.fieldValue(Data, "seafoam", "SEAFOAM_ISLANDS_B3F", + "setsForcedWarp") == true, + "B3F seafoam setsForcedWarp (BIT_FORCED_WARP) is configured") + + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + -- Landing on the B3F force-surf mouth mounts SURF and arms the current + -- (EnterMap CheckForceBikeOrSurf + Seafoam MOVE_OBJECT), including + -- BIT_FORCED_WARP for the south-edge stairs. + Game.stack:push(OW, "SEAFOAM_ISLANDS_B3F", 18, 7, "down") + local cur = Game.stack:top() + check(cur.player.surfing == true, + "B3F force-surf mouth mounts SURF on map entry") + check(cur.forcedWarp == true, + "B3F current arms forcedWarp (BIT_FORCED_WARP)") + check(#cur.scriptMoves > 0 or cur.player.moving, + "unplugged B3F current at (18,7) starts forced surfing RLE") + + -- Fast-forward the scripted current onto the south-edge warp. + local guard = 0 + while guard < 2000 do + guard = guard + 1 + StateStack:update(1 / 60) + if cur.map and cur.map.id ~= "SEAFOAM_ISLANDS_B3F" then break end + if cur.transitioning then break end + end + -- Drain the warp Transition onto B4F (and any immediate forcedExit shove). + guard = 0 + while guard < 400 do + guard = guard + 1 + StateStack:update(1 / 60) + if Game.stack:top() == cur and cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F" + and not cur.transitioning and #cur.scriptMoves == 0 + and not cur.player.moving then + break + end + end + check(cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F", + "B3F current auto-warps to B4F without a held d-pad (#129)") + check(cur.player.cellX == 20 or cur.player.cellX == 21, + "forced current lands on the B4F stair-warp column") +end + S.finish() diff --git a/tests/parity_J.lua b/tests/parity_J.lua index 1882b527..67b61d8c 100644 --- a/tests/parity_J.lua +++ b/tests/parity_J.lua @@ -79,6 +79,58 @@ do "the queued trap anim row is attributed to the attacker") end +-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu +-- (core.asm:312 then 323-329). FIGHT forces CANNOT_MOVE; AI still +-- auto-selects bound. A player switch clears the foe's trap bit +-- (SendOutMon core.asm:1761-1762). +do + local tb = freshBattle() + tb.enemy.trappingTurns = 2 + tb.enemy.trapMove = "WRAP" + tb.enemy.trapDamage = 5 + check(tb:menuLockedAction(tb.player) == nil, + "a Wrap victim still gets the battle menu") + local fight = tb:fightLockedAction(tb.player) + check(fight and fight.special == "bound", + "FIGHT while wrapped selects CANNOT_MOVE (bound)") + -- wrapper continues only after FIGHT, not by skipping the menu + check(tb:menuLockedAction(tb.enemy) == nil, + "a Wrap user still gets the battle menu") + check(tb:fightLockedAction(tb.enemy) and + tb:fightLockedAction(tb.enemy).special == "trapping", + "FIGHT while wrapping continues the trap") + check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound", + "lockedAction still reports bound for AI / callers") +end +do + Game.save.party = { + Pokemon.new(Data, "BULBASAUR", 20), + Pokemon.new(Data, "SQUIRTLE", 20), + } + local tb = BattleState.newWild(Game, "EKANS", 10) + tb.enemy.trappingTurns = 3 + tb.enemy.trapMove = "WRAP" + tb.enemy.trapDamage = 7 + local acts = {} + function tb:act(fn) acts[#acts + 1] = fn end + function tb:actNext(fn) acts[#acts + 1] = fn end + function tb:sayNext() end + function tb:animNext() end + function tb:startGrowIn() end + function tb:syncSides() end + function tb:markParticipant() end + function tb:restoreMimicked() end + function tb:executeAction() end + function tb:endOfTurn() end + function tb:enemyAction() return { special = "bound" } end + tb:resolveSwitch(Game.save.party[2]) + acts[1]() -- send-out clears foe trap + eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.") + eq(tb.enemy.trapMove, nil, "player switch clears trapMove") + check(tb:fightLockedAction(tb.player) == nil, + "switch-in is free to choose a move") +end + -- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the -- move executes, MoveHitTest runs, and only on a hit does the player's -- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link @@ -169,10 +221,35 @@ do check(fe.chooser == nil, "enemy Mimic never opens a chooser") eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK", "enemy Mimic copies a random player move immediately") - eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP") + -- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's + -- full remaining PP (still 10). Player Mimic would leave 9. + eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)") check(fe.anim and hasText(fe, "learned"), "enemy Mimic still plays the animation and learned text") +-- === #94: gen1_faithful enemies never deplete PP; player still does === +do + local tb = freshBattle() + check(tb.ruleset.enemyUnlimitedPP, + "default ruleset grants enemy unlimited PP") + local enemyMove = { id = "TACKLE", pp = 5 } + local playerMove = { id = "TACKLE", pp = 5 } + tb.queue, tb.nextInsert = {}, 0 + tb:performMove(tb.enemy, tb.player, enemyMove) + eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented") + tb.queue, tb.nextInsert = {}, 0 + tb:performMove(tb.player, tb.enemy, playerMove) + eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements") + + -- modern_clean tracks enemy PP (Gen 2+ style) + local modern = require("src.battle.rulesets.modern_clean") + tb.ruleset = modern + local enemyModern = { id = "TACKLE", pp = 5 } + tb.queue, tb.nextInsert = {}, 0 + tb:performMove(tb.enemy, tb.player, enemyModern) + eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements") +end + -- link battle: the player's Mimic rolls random too (no chooser) local tbl = freshBattle() tbl.kind = "link" diff --git a/tests/parity_K.lua b/tests/parity_K.lua index 42d888a0..fd561764 100644 --- a/tests/parity_K.lua +++ b/tests/parity_K.lua @@ -115,11 +115,39 @@ do end -- === STRUGGLE fallback when nothing is usable === +-- modern_clean / no unlimited flag: empty PP forces Struggle. do local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } } - local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data, - player = { mon = {}, curTypes = {} } }) - check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP") + local pick = TrainerAI.chooseMove(aiMon, rngLo, { + enemyAIMods = { 1 }, data = Data, + player = { mon = {}, curTypes = {} }, + ruleset = { enemyUnlimitedPP = false }, + }) + check(pick and pick.struggle and pick.id == "STRUGGLE", + "STRUGGLE fallback when no PP (enemy PP tracked)") +end + +-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable. +do + local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } } + local pick = TrainerAI.chooseMove(aiMon, rngLo, { + enemyAIMods = { 1 }, data = Data, + player = { mon = {}, curTypes = {} }, + ruleset = { enemyUnlimitedPP = true }, + }) + eq(pick and pick.id, "TACKLE", + "gen1_faithful: enemy still picks 0-PP moves (no Struggle)") +end + +-- Disable with unlimited PP: sole disabled move still yields Struggle. +do + local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 } + local pick = TrainerAI.chooseMove(aiMon, rngLo, { + enemyAIMods = {}, data = Data, + ruleset = { enemyUnlimitedPP = true }, + }) + check(pick and pick.struggle and pick.id == "STRUGGLE", + "gen1_faithful: Struggle only when every move is disabled") end -- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) === diff --git a/tests/parity_ai_switch_faint.lua b/tests/parity_ai_switch_faint.lua new file mode 100644 index 00000000..db128f57 --- /dev/null +++ b/tests/parity_ai_switch_faint.lua @@ -0,0 +1,114 @@ +-- Parity test: trainer next-mon after an AI switch must skip fainted slots. +-- +-- Agatha (and any switchChance / switch AI) can withdraw mon 1, send mon 2, +-- then later return to mon 1. If mon 2 was KO'd in between, enemyMonFainted +-- used to do enemyIndex+1 and send the already-fainted Golbat back out. +-- The FIGHT menu reappears with an empty enemy HP bar; executeAction then +-- returns immediately on target.mon.hp <= 0 -- a softlock. +-- +-- pokered's EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck scan the party +-- for the first mon with HP remaining (core.asm), not "current index + 1". +-- +-- Self-contained; run via `luajit tests/parity_ai_switch_faint.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity ai switch faint") +local check, eq = S.check, S.eq + +-- Minimal game stub matching what BattleState.newTrainer / enemyMonFainted +-- touch. SET style skips the SHIFT "change POKéMON?" ChoiceBox. +local function freshGame() + return { + data = Data, + save = { + party = { Pokemon.new(Data, "BULBASAUR", 50) }, + player = { name = "RED" }, + inventory = {}, + options = { battleStyle = "set" }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } +end + +-- Drain act/say rows until the enemy swap act runs (or the queue empties). +local function pumpUntilSwap(b, limit) + limit = limit or 200 + local n = 0 + while #b.queue > 0 and n < limit do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + -- the send-out act replaces self.enemy; stop once a living mon is in + if b.enemy and b.enemy.mon and b.enemy.mon.hp > 0 then + return true + end + end + end + return b.enemy and b.enemy.mon and b.enemy.mon.hp > 0 +end + +-- #143: after AI-switch reorder, do not send a fainted slot. +do + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1) + check(#b.enemyParty >= 3, "Agatha has enough party slots for the scenario") + + -- Simulate: switched to slot 2, KO'd it, switched back to slot 1, KO slot 1. + b.enemyParty[1].hp = 0 + b.enemyParty[2].hp = 0 + -- slots 3+ stay at full HP from construction + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + + b:enemyMonFainted() + check(pumpUntilSwap(b), "a living reserve is sent out after the KO") + eq(b.enemyIndex, 3, "next index is the first living mon, not fainted slot 2") + check(b.enemy.mon.hp > 0, "sent-out mon has HP (no empty-bar softlock)") + check(b.enemy.mon == b.enemyParty[3], "sent-out battler is party slot 3") + check(b.result ~= "win", "battle continues while reserves remain") +end + +-- Sequential KOs (no AI switch) still advance one slot at a time. +do + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + check(pumpUntilSwap(b), "youngster still sends the second mon") + eq(b.enemyIndex, 2, "without AI switch, first living mon is still index 2") + check(b.enemy.mon.hp > 0, "second mon is healthy") +end + +-- No living reserves: victory even if enemyIndex is mid-party (AI left +-- earlier slots dead and the active one was not the last index). +do + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1) + for _, mon in ipairs(b.enemyParty) do mon.hp = 0 end + b.enemyIndex = 2 + b.enemy.mon = b.enemyParty[2] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + eq(b.result, "win", "all-fainted party ends the trainer battle") + eq(b.afterQueue, "finish", "victory finishes after the queue") +end + +S.finish() diff --git a/tests/parity_blackout_warp.lua b/tests/parity_blackout_warp.lua new file mode 100644 index 00000000..af5048c3 --- /dev/null +++ b/tests/parity_blackout_warp.lua @@ -0,0 +1,68 @@ +-- Parity test (#96): blackouts must not play the Dig/Teleport EnterMapAnim. +-- +-- pret HandleBlackOut fades to black and SpecialEnterMap's without setting +-- BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap skips EnterMapAnim. Dig, +-- Teleport and Escape Rope go through HandleFlyWarpOrDungeonWarp and do +-- spin. warpToHealPoint used to always set arriveWarp="teleport", so a +-- loss (notably Elite Four -> Indigo lobby) rematerialized with the +-- teleport spin instead of a plain whiteout warp. +-- Self-contained; run via `luajit tests/parity_blackout_warp.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity blackout warp") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 5) } +Game.save.party[1].hp = 0 +Game.save.lastHeal = { map = "INDIGO_PLATEAU_LOBBY", x = 7, y = 6, + outdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } } + +Game.stack:push(OW, "LORELEIS_ROOM", 4, 5, "up") +local ow = Game.stack:top() +Game.overworld = ow + +local captured +local realStart = ow.startWarpTo +ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts) + captured = self.arriveWarp + -- skip Transition; only the arrive flag matters here + self.arriveWarp = nil + self.transitioning = false +end + +-- Battle blackout (afterBattle -> warpToHealPoint): no EnterMapAnim. +captured = "sentinel" +ow:afterBattle("lose", { oppClass = "OPP_LORELEI" }) +eq(captured, nil, + "Elite Four blackout does not set arriveWarp=teleport (#96)") + +-- Poison / field blackout path uses the same helper with no opts. +captured = "sentinel" +ow.arriveWarp = nil +ow:warpToHealPoint() +eq(captured, nil, "plain warpToHealPoint has no teleport arrive FX") + +-- Dig / Teleport / Escape Rope keep EnterMapAnim. +captured = "sentinel" +ow:warpToHealPoint(nil, { arrive = "teleport" }) +eq(captured, "teleport", + "escape warps still request EnterMapAnim on arrival") + +ow.startWarpTo = realStart +S.finish() diff --git a/tests/parity_dig_pic.lua b/tests/parity_dig_pic.lua new file mode 100644 index 00000000..cc9edd87 --- /dev/null +++ b/tests/parity_dig_pic.lua @@ -0,0 +1,157 @@ +-- Parity: Dig / Fly semi-invulnerable pic hide (#100). +-- Dig charge SLIDE_DOWN hides the user; a cancelled Dig release (miss / +-- type immunity) must restore the pic; a successful Dig release keeps the +-- user hidden through the dirt subanim then emerges via SE_SLIDE_MON_UP +-- (not a cyclic bounce). Self-contained: `luajit tests/parity_dig_pic.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) +local S = require("tests.harness").suite("parity dig pic") +local check, eq = S.check, S.eq + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function makeGame(species, level, moves) + local save = SaveData.newGame() + local mon = Pokemon.new(Data, species, level) + mon.moves = moves + save.party = { mon } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function() return true end } } +end + +local function pumpToMenu(battle) + local kinds, anims = {}, {} + local guard = 0 + while guard < 20000 do + guard = guard + 1 + battle.frame = (battle.frame or 0) + 1 + battle:updateFx() + local pf = battle.picFx and battle.picFx[battle.player] + if pf and pf.kind and not kinds[pf.kind] then + kinds[pf.kind] = true + end + if battle.animPlaying and battle.animName + and anims[#anims] ~= battle.animName then + anims[#anims + 1] = battle.animName + end + if not battle:updateQueue() then + if battle.phase == "messages" and battle.afterQueue == "menu" + and not battle.animPlaying and not battle.current + and #battle.queue == 0 then + battle.phase = "menu" + break + end + if battle.phase == "menu" then break end + if not battle.animPlaying and not battle.current + and #battle.queue == 0 then + if battle.afterQueue == "menu" then battle.phase = "menu" end + break + end + end + end + for _ = 1, 200 do + battle.frame = battle.frame + 1 + battle:updateFx() + end + return kinds, anims +end + +local function picHidden(battle) + local pf = battle.picFx and battle.picFx[battle.player] + return pf and pf.hidden or false +end + +-- Dig charge hides; Dig miss on release restores the pic (#100 vanish). +do + local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } }) + local battle = BattleState.newWild(game, "RATTATA", 5) + battle.player.curMoves = game.save.party[1].moves + local dig = battle.player.curMoves[1] + battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end + battle.rng = function(a) return a or 0 end + battle:resolveTurn(dig) + pumpToMenu(battle) + check(battle.player.invulnerable == true, "Dig charge sets invulnerable") + check(picHidden(battle), "Dig charge leaves the user pic hidden") + + battle.rng = function(a, b) + if a == 0 and b == 255 then return 255 end -- force Dig accuracy miss + return a or 0 + end + battle:resolveTurn(dig) + local kinds, anims = pumpToMenu(battle) + check(not picHidden(battle), + "Dig miss on release restores the user pic (#100)") + check(not kinds.bounce, "Dig release miss never starts a bounce pic fx") + local sawDig = false + for _, name in ipairs(anims) do + if name == "DIG" then sawDig = true end + end + check(not sawDig, "Dig miss cancels the DIG release anim") +end + +-- Dig hit: stay hidden through DIG start, emerge via slideUp (not bounce). +do + local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } }) + local battle = BattleState.newWild(game, "SNORLAX", 40) + battle.player.curMoves = game.save.party[1].moves + local dig = battle.player.curMoves[1] + battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end + battle.rng = function(a) return a or 0 end + battle:resolveTurn(dig) + pumpToMenu(battle) + + battle:resolveTurn(dig) + local hiddenAtDigStart = nil + local kinds = {} + local guard = 0 + while guard < 20000 do + guard = guard + 1 + battle.frame = (battle.frame or 0) + 1 + battle:updateFx() + if battle.animPlaying and battle.animName == "DIG" + and hiddenAtDigStart == nil then + -- right after DIG row starts (resetPicFx already ran) + hiddenAtDigStart = picHidden(battle) + end + local pf = battle.picFx and battle.picFx[battle.player] + if pf and pf.kind then kinds[pf.kind] = true end + if not battle:updateQueue() then + if battle.phase == "messages" and battle.afterQueue == "menu" + and not battle.animPlaying and not battle.current + and #battle.queue == 0 then + battle.phase = "menu" + break + end + if battle.phase == "menu" then break end + if not battle.animPlaying and not battle.current + and #battle.queue == 0 then + if battle.afterQueue == "menu" then battle.phase = "menu" end + break + end + end + end + for _ = 1, 200 do + battle.frame = battle.frame + 1 + battle:updateFx() + end + check(hiddenAtDigStart == true, + "DIG release keeps the digger hidden until SE_SLIDE_MON_UP") + check(kinds.slideUp == true, "Dig release uses slideUp emerge") + check(not kinds.bounce, "Dig release must not bounce (#100)") + check(not picHidden(battle), "Dig hit leaves the user pic shown") +end + +S.finish() diff --git a/tests/parity_e4_after.lua b/tests/parity_e4_after.lua new file mode 100644 index 00000000..d8521d62 --- /dev/null +++ b/tests/parity_e4_after.lua @@ -0,0 +1,109 @@ +-- Parity: Lorelei / Bruno / Agatha push AfterBattle text immediately on +-- win (pokered *EndBattleScript -> DisplayTextID -> TalkToTrainer), not +-- only on a later re-talk. Lance is covered by parity_lance.lua. +-- +-- Sources: scripts/LoreleisRoom.asm, BrunosRoom.asm, AgathasRoom.asm +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.LORELEIS_ROOM) then Data:load() end +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +local S = require("tests.harness").suite("parity e4 after") +local check, eq = S.check, S.eq + +local mapScripts = require("data.scripts.init") + +local CASES = { + { + map = "LORELEIS_ROOM", text = "TEXT_LORELEISROOM_LORELEI", + after = "_LoreleisRoomLoreleiAfterBattleText", + headerMap = "LoreleisRoom", event = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", + npcName = "LORELEISROOM_LORELEI", class = "OPP_LORELEI", + }, + { + map = "BRUNOS_ROOM", text = "TEXT_BRUNOSROOM_BRUNO", + after = "_BrunoAfterBattleText", + headerMap = "BrunosRoom", event = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", + npcName = "BRUNOSROOM_BRUNO", class = "OPP_BRUNO", + }, + { + map = "AGATHAS_ROOM", text = "TEXT_AGATHASROOM_AGATHA", + after = "_AgathaAfterBattleText", + headerMap = "AgathasRoom", event = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", + npcName = "AGATHASROOM_AGATHA", class = "OPP_AGATHA", + }, +} + +for _, c in ipairs(CASES) do + local after = Data.text[c.after] + check(after ~= nil, c.after .. " extracted") + local header = Data:trainerHeader(c.headerMap, 1) + check(header and header.after == c.after, + c.headerMap .. " header wires after-battle label") + + local talk = mapScripts.talkScript(c.map, c.text) + check(type(talk) == "function", c.map .. " talk wraps engageTrainer") + + local pushed = {} + local game = { + data = Data, + save = { + flags = {}, + defeatedTrainers = {}, + player = { name = "RED", rival = "BLUE" }, + }, + stack = { + push = function(_, state) pushed[#pushed + 1] = state end, + }, + } + local npc = { + def = { name = c.npcName, index = 1, trainerClass = c.class, + trainerParty = 1, text = c.text }, + id = c.map .. ":1", + facePlayer = function() end, + } + local engaged = false + local ow = { + trainerDefeated = function(_, n) + return game.save.defeatedTrainers[n.id] == true + end, + engageTrainer = function(_, n, onDone) + engaged = n == npc + game.save.defeatedTrainers[n.id] = true + game.save.flags[c.event] = true + if onDone then onDone() end + end, + } + + talk(game, ow, npc, function() end) + check(engaged, c.map .. " engages on first talk") + check(#pushed == 1 and pushed[1].pages ~= nil, + c.map .. " win pushes after-battle TextBox") + check(#pushed[1].pages > 0, c.map .. " after-battle TextBox has pages") + + -- loss: no after text + pushed, engaged = {}, false + game.save.defeatedTrainers = {} + game.save.flags = {} + ow.engageTrainer = function(_, n, onDone) + engaged = true + if onDone then onDone() end + end + talk(game, ow, npc, function() end) + check(engaged, c.map .. " still engages on loss path") + eq(#pushed, 0, c.map .. " loss does not push after-battle text") + + -- re-talk after win still shows after text (TalkToTrainer after branch) + pushed = {} + game.save.defeatedTrainers[npc.id] = true + game.save.flags[c.event] = true + ow.engageTrainer = function() + error(c.map .. " re-talk must not re-engage") + end + talk(game, ow, npc, function() end) + check(#pushed == 1 and pushed[1].pages ~= nil, + c.map .. " defeated re-talk shows after text") +end + +S.finish() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index d798339a..7247b5c7 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -113,6 +113,22 @@ eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)") local HallOfFame = require("src.ui.HallOfFame") check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") +-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts +-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone) +local hofUi = stack2:top() +eq(hofUi.phase, "mons", "induction opens on the mon showcase phase") +eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)") +-- drive past the scroll so the info box is armed +local scrollGuard = 0 +while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do + scrollGuard = scrollGuard + 1 + hofUi:update(1 / 60) +end +eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)") +eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold") +check(hofUi.timer == 80 or hofUi.timer < 80, + "info hold uses the pokered 80 DelayFrames window") + -- drive induction + full credits with A held (pages are unskippable; A -- only advances the induction and the final THE END wait) pressed.a = true diff --git a/tests/parity_lance.lua b/tests/parity_lance.lua new file mode 100644 index 00000000..c22a4b76 --- /dev/null +++ b/tests/parity_lance.lua @@ -0,0 +1,141 @@ +-- Parity test: Lance room walk-in stays on the floor, and defeat dialogue +-- includes the rival-became-champion after-battle text. +-- +-- Sources: scripts/LancesRoom.asm (WalkToLance / LancesRoomLanceEndBattleScript), +-- text/LancesRoom.asm (_LancesRoomLanceAfterBattleText). +-- Self-contained: run via `luajit tests/parity_lance.lua`; also dofile'd +-- by tests/run_tests.lua's aggregator. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.LANCES_ROOM) then Data:load() end +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +local S = require("tests.harness").suite("parity lance") +local check, eq = S.check, S.eq + +local mapScripts = require("data.scripts.init") +local hooks = mapScripts.get("LANCES_ROOM") +check(hooks and hooks.walkInRoute, "LANCES_ROOM exposes walkInRoute") + +local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- === (1) walk-in RLE lands on the door-lock trigger (6,11) === +do + local x, y = 24, 16 + local steps = 0 + for _, seg in ipairs(hooks.walkInRoute) do + local d = D[seg[1]] + check(d ~= nil, "walk segment direction " .. tostring(seg[1])) + for _ = 1, seg[2] do + x, y = x + d[1], y + d[2] + steps = steps + 1 + end + end + eq(x, 6, "walk-in ends at x=6") + eq(y, 11, "walk-in ends at y=11 (door-lock / arena entry)") + check(steps > 0, "walk-in has steps") +end + +-- === (2) every cell of the route is walkable with the entrance open === +do + local Map = require("src.world.Map") + local def = Data.maps.LANCES_ROOM + local tileset = Data.tilesets[def.tileset] + -- mutate a copy of the block list so we don't poison the registry + local blocks = {} + for i, b in ipairs(def.blocks) do blocks[i] = b end + local copy = {} + for k, v in pairs(def) do copy[k] = v end + copy.blocks = blocks + -- LanceShowOrHideEntranceBlocks with the door unlocked + blocks[6 * def.width + 2 + 1] = 0x31 + blocks[6 * def.width + 3 + 1] = 0x32 + local map = Map.new(copy, tileset) + local x, y = 24, 16 + check(map:isWalkableCell(x, y), "start (24,16) walkable") + for _, seg in ipairs(hooks.walkInRoute) do + local d = D[seg[1]] + for _ = 1, seg[2] do + x, y = x + d[1], y + d[2] + check(map:isWalkableCell(x, y), + ("walk-in cell (%d,%d) is floor, not void/wall"):format(x, y)) + end + end +end + +-- === (3) after-battle text names the rival as the real champion === +do + local after = Data.text._LancesRoomLanceAfterBattleText + check(after ~= nil, "_LancesRoomLanceAfterBattleText extracted") + check(after:find("ELITE", 1, true) or after:find("{RIVAL}", 1, true), + "after text mentions rival / Elite Four") + check(after:find("champion", 1, true) or after:find("CHAMPION", 1, true), + "after text has the champion reveal") + check(after:find("before you", 1, true) or after:find("before you!", 1, true) + or after:find("FOUR before", 1, true), + "after text says rival beat the Elite Four first") + local header = Data:trainerHeader("LancesRoom", 1) + check(header and header.after == "_LancesRoomLanceAfterBattleText", + "trainer header wires the after-battle label") + check(header and header.won == "_LancesRoomLanceEndBattleText", + "trainer header wires the won label") +end + +-- === (4) onStep win callback pushes the after text (not only won text) === +do + local pushed = {} + local game = { + data = Data, + save = { + flags = {}, + defeatedTrainers = {}, + player = { name = "RED", rival = "BLUE" }, + }, + stack = { + push = function(_, state) + pushed[#pushed + 1] = state + end, + }, + } + local lance = { + def = { name = "LANCESROOM_LANCE", index = 1, + trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 }, + id = "LANCES_ROOM:1", + facePlayer = function() end, + } + local engaged = false + local ow = { + npcs = { lance }, + player = { cellX = 6, cellY = 2 }, + trainerDefeated = function(_, npc) + return game.save.defeatedTrainers[npc.id] == true + end, + engageTrainer = function(_, npc, onDone) + engaged = npc == lance + -- simulate engageTrainer's win path: flag the trainer, then onDone + game.save.defeatedTrainers[npc.id] = true + game.save.flags.EVENT_BEAT_LANCES_ROOM_TRAINER_0 = true + if onDone then onDone() end + end, + } + local handled = hooks.onStep(game, ow, 6, 2) + check(handled == true, "Lance coord trigger engages") + check(engaged, "engageTrainer called for Lance") + check(#pushed == 1 and pushed[1].pages ~= nil, "after-battle TextBox pushed") + -- TextBox.substitute already expanded {RIVAL}/{PLAYER}; page glyphs + -- are opaque, so re-check the source label via the header + that a + -- box was queued on win only. + check(#pushed[1].pages > 0, "after-battle TextBox has pages") + -- loss path must not show after text + pushed = {} + game.save.defeatedTrainers = {} + ow.engageTrainer = function(_, npc, onDone) + -- lose: do not mark defeated + if onDone then onDone() end + end + hooks.onStep(game, ow, 6, 2) + eq(#pushed, 0, "loss does not push after-battle text") +end + +print("parity_lance: ok") diff --git a/tests/parity_red_snes.lua b/tests/parity_red_snes.lua new file mode 100644 index 00000000..67f409f1 --- /dev/null +++ b/tests/parity_red_snes.lua @@ -0,0 +1,57 @@ +-- Parity test: Red's bedroom SNES (#135). +-- +-- pokered hidden_events.asm REDS_HOUSE_2F: +-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING +-- PrintRedSNESText shows _RedBedroomSNESText ("{PLAYER} is playing the SNES!"). +-- +-- Self-contained; run via `luajit tests/parity_red_snes.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.REDS_HOUSE_2F) then Data:load() end +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +require("data.scripts.init") +local MapScripts = require("src.script.MapScripts") +local S = require("tests.harness").suite("parity red snes") +local check, eq = S.check, S.eq + +check(Data.text._RedBedroomSNESText ~= nil, "_RedBedroomSNESText is extracted") +check(Data.text._RedBedroomSNESText:find("SNES", 1, true), + "_RedBedroomSNESText mentions the SNES") + +local hooks = MapScripts.get("REDS_HOUSE_2F") +check(hooks and type(hooks.onInteract) == "function", + "REDS_HOUSE_2F registers onInteract for the SNES") + +-- stub just enough of the overworld/game stack to exercise the hook +local pushed +local game = { + data = Data, + save = { player = { name = "RED", rival = "BLUE" } }, + stack = { + push = function(_, state) pushed = state end, + }, +} +local ow = { player = { facing = "up" } } + +eq(hooks.onInteract(game, ow, 0, 1), false, + "bedroom PC tile is not claimed by the SNES hook") +eq(hooks.onInteract(game, ow, 3, 6), false, + "spawn tile is not claimed by the SNES hook") + +pushed = nil +eq(hooks.onInteract(game, ow, 3, 5), true, + "SNES at (3,5) consumes the interact") +check(pushed ~= nil and pushed.pages ~= nil, "SNES interact pushes a TextBox") +local flat = table.concat(pushed.pages[1] or {}, "\n") +check(flat:find("SNES", 1, true) and flat:find("RED", 1, true), + "SNES TextBox shows the bedroom SNES line with the player name") + +-- ANY_FACING: facing is irrelevant once the faced cell is (3,5) +ow.player.facing = "left" +pushed = nil +eq(hooks.onInteract(game, ow, 3, 5), true, + "SNES still fires when the player faces it from the side") + +S.finish() diff --git a/tests/parity_seam_walk_anim.lua b/tests/parity_seam_walk_anim.lua new file mode 100644 index 00000000..fad0a1bf --- /dev/null +++ b/tests/parity_seam_walk_anim.lua @@ -0,0 +1,77 @@ +-- Regression: map-connection seam steps must show walk frames (issue #93). +-- +-- A hitch inside setMap (neighbor rebuild / map-song start) made the next +-- real-time dt huge; FixedStep catch-up then advanced many walk frames +-- before the next draw, which looked like a slide with no leg animation. +-- crossConnection now discards that catch-up, starts a fresh animClock, +-- and defers PlayMapMusic until the seam step lands. +-- +-- Self-contained; run via `luajit tests/parity_seam_walk_anim.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local FixedStep = require("src.core.FixedStep") +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Music = require("src.core.Music") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity seam walk anim") +local check, eq = S.check, S.eq + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up") +local ow = Game.stack:top() +local north = ow.map:connection("north") +check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1") + +-- simulate a post-hitch catch-up budget waiting in the accumulator +FixedStep.accum = 0.24 +local played = {} +local realPlayMap = Music.playMap +Music.playMap = function(data, mapId, onBike, surfing) + played[#played + 1] = mapId + return realPlayMap(data, mapId, onBike, surfing) +end + +check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses") +eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1") +eq(FixedStep.accum, 0, "seam cross discards FixedStep catch-up") +eq(ow.pendingSeamMusic, "ROUTE_1", "map music deferred across the seam step") +eq(#played, 0, "PlayMapMusic not called inside setMap for the seam") +eq(ow.player.animClock, 0, "seam step starts a fresh walk-cycle clock") +check(ow.player.moving, "seam step is in progress") + +local sawWalk = false +local phases = {} +for _ = 1, 20 do + ow:update(1 / 60) + phases[#phases + 1] = ow.player:walkPhase() + if ow.player:walkPhase() == 1 then sawWalk = true end + if not ow.player.moving and not ow.pendingSeamMusic then break end +end +check(sawWalk, "seam step shows at least one walk frame") +-- mid-cycle: frames 4..11 of a fresh animClock are walk +local midWalk = false +for i = 4, 11 do + if phases[i] == 1 then midWalk = true break end +end +check(midWalk, "fresh animClock puts walk frames in the middle of the seam step") +eq(ow.pendingSeamMusic, nil, "deferred music flushed after the seam step") +eq(played[1], "ROUTE_1", "PlayMapMusic runs once the seam step lands") + +Music.playMap = realPlayMap +S.finish() diff --git a/tests/parity_status_onomatopoeia.lua b/tests/parity_status_onomatopoeia.lua new file mode 100644 index 00000000..02984800 --- /dev/null +++ b/tests/parity_status_onomatopoeia.lua @@ -0,0 +1,125 @@ +-- Parity: sleep/confusion onomatopoeia on status-check text +-- (core.asm CheckPlayerStatusConditions / CheckEnemyStatusConditions). +-- Self-contained: `luajit tests/parity_status_onomatopoeia.lua`; also +-- dofile'd by tests/run_tests.lua's parity_* aggregator. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity status onomatopoeia") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") + +local function freshBattle() + Game.save.party = { Pokemon.new(Data, "NIDOKING", 40) } + return BattleState.newWild(Game, "DEWGONG", 30) +end + +-- Capture queue order of anim/text rows inserted via *Next helpers. +local function capture(battle) + local seq = {} + battle.nextInsert = 0 + battle.queue = {} + battle.animNext = function(_, name, isPlayer) + seq[#seq + 1] = { kind = "anim", name = name, isPlayer = isPlayer } + end + battle.sayNext = function(_, text) + seq[#seq + 1] = { kind = "text", text = text } + end + return seq +end + +-- --- sleep: player anim-before-text, enemy text-before-anim ------------- +do + local b = freshBattle() + b.rng = function() return 255 end + local seq = capture(b) + b.player.mon.status = "SLP" + b.player.sleepTurns = 3 + check(b:statusInterrupt(b.player, b.enemy) == true, "sleep interrupts the turn") + eq(#seq, 2, "sleep queues anim + text") + eq(seq[1].kind, "anim", "player sleep: SLP_PLAYER_ANIM before text") + eq(seq[1].name, "SLP_PLAYER_ANIM", "player sleep uses SLP_PLAYER_ANIM") + eq(seq[1].isPlayer, true, "player sleep anim faces the player") + check(seq[2].text:find("is fast asleep!", 1, true), + "player sleep text follows the anim") +end + +do + local b = freshBattle() + b.rng = function() return 255 end + local seq = capture(b) + b.enemy.mon.status = "SLP" + b.enemy.sleepTurns = 3 + check(b:statusInterrupt(b.enemy, b.player) == true, "enemy sleep interrupts") + eq(seq[1].kind, "text", "enemy sleep: FastAsleepText before anim") + check(seq[1].text:find("Enemy ", 1, true), + "enemy sleep text carries the Enemy prefix") + eq(seq[2].name, "SLP_ANIM", "enemy sleep uses SLP_ANIM (enemy Z coords)") + eq(seq[2].isPlayer, false, "enemy sleep anim faces the enemy") +end + +do + local b = freshBattle() + local seq = capture(b) + b.player.mon.status = "SLP" + b.player.sleepTurns = 3 + check(b:preRechargeChecks(b.player, b.enemy) == true, + "pre-recharge sleep still loses the turn") + eq(seq[1].name, "SLP_PLAYER_ANIM", + "pre-recharge sleep plays the same onomatopoeia") +end + +-- --- confusion: text then CONF_*_ANIM (both sides) --------------------- +-- Status.beforeMove: rng(0,255) < 128 -> hurt itself; else can still move. +do + local b = freshBattle() + b.rng = function() return 200 end -- no self-hit + local seq = capture(b) + b.player.confusedTurns = 3 + b.player.mon.status = nil + local stopped = b:statusInterrupt(b.player, b.enemy) + check(stopped == false, "confusion can still allow a move") + eq(seq[1].kind, "text", "player confusion: IsConfusedText before anim") + check(seq[1].text:find("is confused!", 1, true), "player confusion text") + eq(seq[2].name, "CONF_PLAYER_ANIM", "player confusion uses CONF_PLAYER_ANIM") + eq(seq[2].isPlayer, true, "player confusion anim faces the player") +end + +do + local b = freshBattle() + b.rng = function() return 0 end -- self-hit + local seq = capture(b) + b.computeDamage = function() return 1 end + b.applyDamage = function() end + b.onFaint = function() end + b.enemy.confusedTurns = 3 + b.enemy.mon.status = nil + local stopped = b:statusInterrupt(b.enemy, b.player) + check(stopped == true, "confusion self-hit interrupts") + eq(seq[1].kind, "text", "enemy confusion text first") + check(seq[1].text:find("is confused!", 1, true), "enemy confusion text") + eq(seq[2].name, "CONF_ANIM", "enemy confusion uses CONF_ANIM") + eq(seq[2].isPlayer, false, "enemy confusion anim faces the enemy") + check(seq[3] and seq[3].text:find("hurt itself", 1, true), + "hurt-itself text follows the confusion anim") +end + +-- wake stays text-only (no onomatopoeia) +do + local b = freshBattle() + b.rng = function() return 0 end + local seq = capture(b) + b.player.mon.status = "SLP" + b.player.sleepTurns = 1 + b:statusInterrupt(b.player, b.enemy) + eq(#seq, 1, "waking up is text-only") + check(seq[1].text:find("woke up!", 1, true), "wake text") +end + +S.finish() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 34cc91db..340c4fb7 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -222,16 +222,21 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL") "DUX post-trade script completes") shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") --- === 9) Celadon Eevee: no confirm prompt, GotMonText, ball hidden === +-- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText, +-- ball hidden (GivePokemon -> AddPartyMon AskName; script still +-- prints GotMonText after the silent give_pokemon row) === local EEVEE_MAP, EEVEE_BALL = "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" Game.save = SaveData.newGame() check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") -shownIs({ "_GotMonText" }, "Eevee gives immediately (no ask prompt)") +shownIs({ "_DoYouWantToNicknameText", "_GotMonText" }, + "Eevee gives immediately (nickname ask, then GotMonText)") eq(#Game.save.party, 1, "Eevee joins the party") eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE") eq(Game.save.party[1].level, 25, "Eevee is level 25") +check(Game.save.party[1].nickname == "AAAAAAAAAA", + "Eevee nickname prompt accepted (A-mash NamingScreen)") check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "EVENT_GOT_EEVEE bookkeeping set") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden") check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned") diff --git a/tests/parity_victory_road_hole.lua b/tests/parity_victory_road_hole.lua new file mode 100644 index 00000000..f2d6da60 --- /dev/null +++ b/tests/parity_victory_road_hole.lua @@ -0,0 +1,63 @@ +-- Parity test: Victory Road 3F hole at (23,15) dungeon-warps the player +-- to VICTORY_ROAD_2F (22,16). +-- +-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript feeds +-- .SwitchOrHoleCoords into IsPlayerOnDungeonWarp with destination +-- VICTORY_ROAD_2F; data/maps/special_warps.asm DungeonWarpList entry +-- (VICTORY_ROAD_2F, 2) lands at DungeonWarpData (22, 16). The same cell +-- also drops a boulder (onBoulderMoved), but the player fall was missing +-- -- CAVERN $22 is walkable, so without onStep Red stood on the hole +-- (GitHub #86). +-- +-- Self-contained; run via `luajit tests/parity_victory_road_hole.lua`. +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 victory road hole") +local check, eq = S.check, S.eq + +local M = dofile("data/scripts/story.lua") +local vr3 = M.VICTORY_ROAD_3F +check(vr3 ~= nil and vr3.onStep ~= nil, "VICTORY_ROAD_3F has an onStep hole trigger") + +local function owRecording() + local warps = {} + return { + player = { facing = "right" }, + startWarpTo = function(_, mapId, x, y, facing) + warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing } + end, + _warps = warps, + }, warps +end + +-- Stepping onto the hole falls through to 2F at the dungeon-warp landing. +do + local ow, warps = owRecording() + local handled = vr3.onStep({}, ow, 23, 15) + check(handled, "stepping on (23,15) is consumed") + eq(#warps, 1, "exactly one dungeon warp fires") + eq(warps[1].mapId, "VICTORY_ROAD_2F", "destination is VICTORY_ROAD_2F") + eq(warps[1].x, 22, "lands at x=22") + eq(warps[1].y, 16, "lands at y=16") + eq(warps[1].facing, "right", "facing is preserved across the fall") +end + +-- Any other cell is ignored (switch at 3,5 is boulder-only). +do + local ow, warps = owRecording() + eq(vr3.onStep({}, ow, 3, 5), false, "the switch cell does not dungeon-warp") + eq(vr3.onStep({}, ow, 22, 15), false, "a neighboring floor cell is ignored") + eq(#warps, 0, "no warp fires off the hole") +end + +-- The hole collision tile stays walkable (fall, do not block). +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.VICTORY_ROAD_3F) then Data:load() end +local MapLoader = require("src.world.MapLoader") +local map = MapLoader.load(Data, "VICTORY_ROAD_3F") +check(map:isWalkableCell(23, 15), "CAVERN hole tile at (23,15) is walkable") +eq(map:warpPadOrHoleAt(23, 15), "hole", "collision tile is the CAVERN hole ($22)") +check(map:warpAtCell(23, 15) == nil, + "the hole is a dungeon warp, not a map warp event") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 19948154..738d39fb 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1659,6 +1659,47 @@ do eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y") end +-- ---------------------------------------------------------------- dpi fit scale (#87) +-- Android density is often non-integer; fitScale must use framebuffer +-- pixels so each GB pixel maps to a whole number of physical pixels. +do + local Renderer = require("src.render.Renderer") + local Zoom = require("src.render.Zoom") + local g = love.graphics + local oldDim, oldPix, oldDpi = g.getDimensions, g.getPixelDimensions, g.getDPIScale + + -- desktop / dpi=1: identical to the pre-fix unit-based floor scale + g.getDimensions = function() return 1920, 1080 end + g.getPixelDimensions = function() return 1920, 1080 end + g.getDPIScale = function() return 1 end + eq(Renderer:fitScale(), 7, "dpi=1 1080p fitScale is floor(1080/144)=7") + + -- density 1.5 on a 1920x1080 panel → LOVE units 1280x720 + g.getDimensions = function() return 1280, 720 end + g.getPixelDimensions = function() return 1920, 1080 end + g.getDPIScale = function() return 1.5 end + eq(Renderer:fitScale(), 7, + "non-integer density still picks integer framebuffer pixels (7, not 5)") + -- old unit-only math would have returned floor(min(1280/160,720/144))=5 + -- and 5*1.5=7.5 physical px/GB px (shimmer). 7 physical is crisp. + + Zoom.reset() + local vw, vh = Renderer:worldViewSize() + check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)") + -- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156 + eq(vw, 276, "world fill width covers the unit window at pixel scale 7") + eq(vh, 156, "world fill height covers the unit window at pixel scale 7") + + -- missing pixel API falls back to getDimensions (headless / old stub) + g.getPixelDimensions = nil + g.getDPIScale = nil + g.getDimensions = function() return 640, 576 end + eq(Renderer:fitScale(), 4, "no pixel API: fitScale uses unit dimensions") + + g.getDimensions, g.getPixelDimensions, g.getDPIScale = oldDim, oldPix, oldDpi + Zoom.reset() +end + -- ---------------------------------------------------------------- spawn filter do local OW = require("src.world.OverworldController") @@ -2445,6 +2486,94 @@ do end end +-- ================= issue #142: player backsprite X ================= +do +-- pret/pokered places the player mon pic at hlcoord 1,5 (screen x=8). +-- Drawing at x=16 put every backsprite one tile too far right. +do + local img = { + getWidth = function() return 28 end, + getHeight = function() return 28 end, + } + local battle = setmetatable({ + showPlayerBack = false, + safari = false, + demo = false, + sendingOut = false, + phase = "command", + player = { sprite = img, isPlayer = true }, + }, BattleState) + function battle:picImage(i) return i end + function battle:growInScale() return nil end + function battle:fxHidden() return false end + + local xs = {} + local origDraw = love.graphics.draw + love.graphics.draw = function(drawn, x, y, r, sx, sy) + if drawn == img then xs[#xs + 1] = x end + end + battle:drawPicsLayer(0, 0, 0) + love.graphics.draw = origDraw + eq(xs[1], 8, "player backsprite rests at hlcoord 1,5 (x=8)") + + -- trainer/old-man back pic uses the same slot + battle.showPlayerBack = true + battle.playerBackPic = img + xs = {} + love.graphics.draw = function(drawn, x, y, r, sx, sy) + if drawn == img then xs[#xs + 1] = x end + end + battle:drawPicsLayer(0, 0, 0) + love.graphics.draw = origDraw + eq(xs[1], 8, "player/old-man back pic also rests at x=8") +end + +-- Front pics: LoadUncompressedSpriteData centers in a 7x7 buffer at +-- hlcoord 12,0. A 5x5 (40x40) Squirtle rests at (104,16), not +-- right/bottom-aligned to (112,8). +do + local front = { + getWidth = function() return 40 end, + getHeight = function() return 40 end, + } + local battle = setmetatable({ + showEnemyTrainer = false, + enemyHidden = false, + enemySendingOut = false, + phase = "command", + enemy = { sprite = front, isPlayer = false }, + }, BattleState) + function battle:picImage(i) return i end + function battle:growInScale() return nil end + function battle:fxHidden() return false end + function battle:drawBattlerPic(b, x, y, scale) + battle._ex, battle._ey, battle._scale = x, y, scale + end + battle:drawPicsLayer(0, 0, 0) + eq(battle._ex, 104, "enemy 5x5 front rests at hlcoord 12,0 + hPad 1 (x=104)") + eq(battle._ey, 16, "enemy 5x5 front bottom-aligned in 7x7 (y=16)") + eq(battle._scale, 1, "enemy front draws at 1x") +end + +-- Battle message lines skip a tile row (14 then 16), matching the menu. +do + local Font = require("src.render.Font") + local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox + Font.drawBox = function() end + Font.drawCode = function(_, _, y) ys[#ys + 1] = y end + local battle = setmetatable({ + phase = "messages", + current = true, + charIndex = 999, + lines = { { 0x80 }, { 0x81 } }, + }, BattleState) + battle:drawTextArea() + Font.drawCode, Font.drawBox = origCode, origBox + eq(ys[1], 112, "battle text line 1 at row 14 (y=112)") + eq(ys[2], 128, "battle text line 2 at row 16 (y=128)") +end +end + -- ================= BUGS.md batch: ledge-shadow ================= do -- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block ==