Compare commits

...

5 Commits

Author SHA1 Message Date
bryanthaboi 3a55c0b1a5 more and more bugs 2026-07-24 09:24:55 -04:00
bryanthaboi e62ad3f2a4 Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win 2026-07-24 09:04:08 -04:00
bryanthaboi b40df05d2a more buggies 2026-07-24 09:00:19 -04:00
bryanthaboi 5312e34997 buggies 2026-07-24 08:45:16 -04:00
bryanthaboi f0b53e28ab main menu scrollable when over 8 items 2026-07-24 08:29:09 -04:00
63 changed files with 3291 additions and 361 deletions
+16 -15
View File
@@ -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)
+22
View File
@@ -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,
},
}
+1
View File
@@ -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",
+7 -4
View File
@@ -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
+85 -36
View File
@@ -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 "<PLAYER> 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 "<PLAYER> 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,
},
}
+67 -8
View File
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until defeated.
@@ -412,14 +456,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 +501,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 +524,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)
+68 -18
View File
@@ -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
+215 -49
View File
@@ -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)
+36 -27
View File
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
-- the hit blink + damage sound ride each animation row, placed BEFORE
-- that hit's drain so the blink precedes the bar. Multi-hit moves
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
if h == 1 then
hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
else
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
+7 -2
View File
@@ -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
+3
View File
@@ -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,
}
+2
View File
@@ -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,
}
+8
View File
@@ -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
+6 -1
View File
@@ -322,7 +322,9 @@ function Game:keypressed(key)
return
elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
@@ -443,12 +445,15 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered)
+5
View File
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b",
["kpenter"] = "start", escape = "start",
-- Select: fight-menu move reorder + bag item reorder. Tab is the
-- discoverable default (shown in CONTROLS); both shifts stay as
-- aliases so Right-Shift muscle memory from older builds still works.
tab = "select",
rshift = "select",
lshift = "select",
}
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
+19
View File
@@ -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.
+1 -1
View File
@@ -48,7 +48,7 @@ local KEY = {
a = "z",
b = "x",
start = "escape",
select = "rshift",
select = "tab",
}
local function nowMs()
+1 -1
View File
@@ -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)
}
+49 -27
View File
@@ -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
+6
View File
@@ -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 = "?" }
+29 -1
View File
@@ -22,6 +22,15 @@ GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
function GBCFX.isSupported()
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
@@ -222,6 +232,10 @@ function GBCFX.shader()
end
function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
@@ -230,12 +244,26 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end
function GBCFX.levelLabel(level)
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
end
function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current
+8 -5
View File
@@ -353,18 +353,21 @@ local TILESET_GROUP_EXCEPTIONS = {
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
}
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile.
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
local LOBBY_TABLE_TOP_ALIAS = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
}
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
+49 -15
View File
@@ -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
+23 -2
View File
@@ -55,8 +55,29 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0
function TileRenderer.tick()
animFrame = animFrame + 1
local animAccum = 0
local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
-- Advance water/flower/spinner tile animation. Called from the overworld
-- draw path so it keeps running under dialogs (overworld update does not),
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
-- no longer speeds up or slows the cycle (issue #4).
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
-- advances exactly one frame per call.
function TileRenderer.tick(dt)
if dt == nil and love and love.timer and love.timer.getDelta then
dt = love.timer.getDelta()
end
if dt == nil then
animFrame = animFrame + 1
return
end
-- Cap catch-up so a long stall cannot jump many water/flower periods
animAccum = math.min(animAccum + dt, 0.25)
while animAccum >= ANIM_STEP do
animAccum = animAccum - ANIM_STEP
animFrame = animFrame + 1
end
end
-- ------------------------------------------------------------------
+49 -2
View File
@@ -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 <species> <level>: _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 {}
+3 -1
View File
@@ -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!" })
+22 -4
View File
@@ -11,7 +11,9 @@ local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button
-- Input.lua's map, primary key first where several keys share a button.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
@@ -20,7 +22,7 @@ local BUTTONS = {
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" },
{ id = "select", label = "SELECT", key = "rshift" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
}
-- a binding is a plain key string or { key, pad }; absent = the fixed
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
return def.key
end
local function boundPad(overlay, def)
local b = overlay and overlay[def.id]
if type(b) == "table" and b.pad then return b.pad end
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
end
function BindingsMenu.new(game)
local overlay = game.save and game.save.options
and game.save.options.bindings
local items = {}
for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label,
right = boundKey(overlay, def):upper(), button = def }
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
end
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper()
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
+204 -79
View File
@@ -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 <LV> 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
+43 -4
View File
@@ -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
+18 -1
View File
@@ -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
@@ -102,7 +110,7 @@ local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
return {
local rows = {
{ id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g)
@@ -244,6 +252,15 @@ local function buildRows(game)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
end
rows = filtered
end
return rows
end
function OptionsMenu.new(game)
+11 -3
View File
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -294,7 +296,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
@@ -353,8 +357,12 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
@@ -373,7 +381,7 @@ function PartyMenu:update(dt)
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
+11 -1
View File
@@ -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)
+33 -5
View File
@@ -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)
+4 -1
View File
@@ -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",
+97 -25
View File
@@ -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
@@ -2401,6 +2431,27 @@ function OverworldState:runVictoryHook()
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
end
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end
local function calcDiff(a, b)
a, b = u8(a), u8(b)
return a >= b and a - b or b - a
end
local function trainerSightPixelDist(npc, player, horizontal)
if horizontal then
return calcDiff(PLAYER_SCREEN_X,
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
end
return calcDiff(PLAYER_SCREEN_Y,
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end
-- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight()
@@ -2418,22 +2469,23 @@ function OverworldState:checkTrainerSight()
local range = header and header.range or 0
local vec = DIRVEC[npc.facing]
if range > 0 and vec then
local dist
local dist, horizontal
if vec[1] ~= 0 and npc.cellY == p.cellY then
dist = (p.cellX - npc.cellX) * vec[1]
horizontal = true
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
dist = (p.cellY - npc.cellY) * vec[2]
horizontal = false
end
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen
-- coordinates only (home/trainers.asm, engine/overworld/
-- trainer_sight.asm) -- there is no line-of-sight obstruction check.
-- An aligned trainer within range engages through interposed NPCs and
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also
-- ignores collision, so the trainer simply walks/overlaps through
-- anything on the line -- exactly as OAM sprites overlap on hardware.
if dist and dist >= 1 and dist <= range then
self:startTrainerApproach(npc, dist)
return
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
-- same facing-line rule as before, but the $fc Y quirk excludes the
-- 4-tiles-north tile that cell math would still count as in range.
if dist and dist >= 1 then
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
if pixelDist > 0 and pixelDist <= range * 16 then
self:startTrainerApproach(npc, dist)
return
end
end
end
end
@@ -2669,8 +2721,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 +2806,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 +2983,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 +3007,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 +3221,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 +3272,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
@@ -3444,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end
function OverworldState:drawWorld()
-- advance the water/flower tile animation (runs under dialogs too)
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
require("src.render.TileRenderer").tick()
-- let the renderer know whether a spinner puzzle is currently sliding
-- the player, so it can flicker the arrow tiles between the blur and
+20 -4
View File
@@ -81,6 +81,14 @@ end
-- Advance one fixed step; returns true when a step just completed.
function Player:update()
-- land-frame walk pose lasts only through the draw after completion;
-- the next update (idle or a chained step) clears it
self.stepLanded = false
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
-- not love.draw's display refresh (issue #4: >59fps ended early).
if self.hopFrames and self.hopFrames > 0 then
self.hopFrames = self.hopFrames - 1
end
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
@@ -109,6 +117,11 @@ function Player:update()
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.stepFlip = not self.stepFlip
-- keep animClock's pose on this frame (issue #82): bike steps land
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
-- stand whenever moving cleared — a stand flash every tile on the
-- bike, and sometimes after dismount when the clock is desynced
self.stepLanded = true
return true
end
return false
@@ -119,7 +132,7 @@ function Player:facingCell()
end
function Player:walkPhase()
if not self.moving then return 0 end
if not self.moving and not self.stepLanded then return 0 end
-- walk frame during the middle of each 16-frame animation cycle
local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0
@@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY)
local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
-- hopFrames counts down in Player:update (fixed step), never here.
if self.hopFrames and self.hopFrames > 0 then
self.hopFrames = self.hopFrames - 1
local t = 1 - self.hopFrames / (self.hopTotal or 32)
local total = self.hopTotal or 32
-- update runs before draw, so remaining N means N steps already
-- consumed this hop → t matches the old draw-side post-decrement phase
local t = 1 - self.hopFrames / total
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
+3
View File
@@ -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 = {
+18
View File
@@ -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
@@ -618,6 +620,22 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
do
local aliases = PaletteFX.TILE_ALIASES
local roof = aliases and aliases.CELADON_MART_ROOF
local diner = aliases and aliases.CELADON_DINER
check(roof ~= nil and diner ~= nil,
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
check(diner == roof,
"diner reuses the same lobby table-top alias as the mart roof")
local al = diner and diner[1]
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[5] and al.cells[6]
and al.cells[9] and al.cells[10],
"lobby table-top alias remaps block 29 cells 5/6/9/10")
end
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
do
+35
View File
@@ -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
+101 -6
View File
@@ -295,7 +295,8 @@ check(bm.screenId == "BindingsMenu",
check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
"with no rebind the rows mirror the fixed map")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
@@ -476,7 +477,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 +499,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" } },
+49 -1
View File
@@ -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()
+44 -6
View File
@@ -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()
+51
View File
@@ -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()
+44
View File
@@ -432,6 +432,50 @@ check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
popToOW()
-- ===========================================================================
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
-- building allows Fly like any other outdoor overworld map.
-- ===========================================================================
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
Game.save.inventory = { THUNDERBADGE = true }
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
local pmIndigo = PartyMenu.new(Game)
Game.stack:push(pmIndigo)
frame({ "a" })
local actsIndigo = submenuActions(pmIndigo)
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
popToOW()
ow = pushOW("ROUTE_23", 10, 6, "down")
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
local pmR23 = PartyMenu.new(Game)
Game.stack:push(pmR23)
frame({ "a" })
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
popToOW()
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
"Indigo lobby is not an outside tileset")
local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby)
frame({ "a" })
local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
-- STRENGTH activation from a fainted user (name text + strengthActive)
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
+78 -1
View File
@@ -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"
+31 -3
View File
@@ -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) ===
+114
View File
@@ -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()
+97
View File
@@ -0,0 +1,97 @@
-- Regression: bike / desynced walk steps must not flash stand on land (issue #82).
--
-- walkPhase used to return 0 whenever moving was false. A step clears
-- moving on its final FixedStep tick, so the draw after landing snapped
-- to stand even when animClock was mid walk-cycle. Bike steps are 8
-- frames, so they land at animClock % 16 == 8 (walk) every tile — always
-- stuttery. Walking after a bike ride inherits a desynced animClock and
-- hit the same stand flash "sometimes."
--
-- Self-contained; run via `luajit tests/parity_bike_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 Player = require("src.world.Player")
local S = require("tests.harness").suite("parity bike walk anim")
local check, eq = S.check, S.eq
local function makePlayer(onBike)
local p = Player.new(Data, 5, 5, "down")
p.onBike = onBike
if onBike then
p.stepFramesCur = p.bikeStepFrames or 8
else
p.stepFramesCur = p.stepFrames or 16
end
p.animClock = 0
return p
end
local function startStep(p)
p.moving = true
p.progress = 0
p.targetX, p.targetY = p.cellX, p.cellY + 1
end
-- --- bike: every land frame mid-cycle must stay walk ---
local bike = makePlayer(true)
local landPhases = {}
for tile = 1, 4 do
startStep(bike)
local stepLen = bike.stepFramesCur
for _ = 1, stepLen do
local done = bike:update()
if done then
landPhases[#landPhases + 1] = bike:walkPhase()
eq(bike.moving, false, "bike step clears moving on land")
check(bike.stepLanded, "bike land latches stepLanded for draw")
end
end
end
-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0
-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0
eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose")
eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)")
eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again")
eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again")
-- continuous bike phases across two tiles: no forced stand in the walk band
bike = makePlayer(true)
local phases = {}
for _ = 1, 2 do
startStep(bike)
for _ = 1, bike.stepFramesCur do
bike:update()
phases[#phases + 1] = bike:walkPhase()
end
end
-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11
for i = 4, 11 do
eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i)
end
-- idle after land: next update drops the latch → stand
bike = makePlayer(true)
startStep(bike)
for _ = 1, bike.stepFramesCur do bike:update() end
eq(bike:walkPhase(), 1, "latched land frame still walk")
bike:update()
eq(bike.stepLanded, false, "idle update clears stepLanded")
eq(bike:walkPhase(), 0, "truly idle is stand")
-- --- walk after desynced animClock (post-bike): land must not force stand ---
local walk = makePlayer(false)
walk.animClock = 8 -- leftover from a bike half-cycle
startStep(walk)
local lastPhase
for _ = 1, walk.stepFramesCur do
walk:update()
lastPhase = walk:walkPhase()
end
eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle")
eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)")
S.finish()
+68
View File
@@ -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()
+157
View File
@@ -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()
+109
View File
@@ -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()
+41
View File
@@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
GBCFX.setLevel(0)
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
local prevSystem = love.system
love.system = { getOS = function() return "Android" end }
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
GBCFX.setLevel(3)
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
local opts = { gbcfx = 4 }
check(GBCFX.applyOptions(opts) == true,
"applyOptions reports a cleared persisted level on Android")
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
check(not GBCFX.active(), "active() is false on Android")
eq(GBCFX.shader(), nil, "shader() is nil on Android")
love.system = { getOS = function() return "iOS" end }
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
love.system = prevSystem
check(GBCFX.isSupported(), "support restores when OS stub is removed")
-- desktop path still applies a level after leaving the mobile gate
GBCFX.applyOptions({ gbcfx = 2 })
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
GBCFX.setLevel(0)
-- Options menu hides the GBC FX row on Android
local OptionsMenu = require("src.ui.OptionsMenu")
love.system = { getOS = function() return "Android" end }
local om = OptionsMenu.new({
data = { rulesets = {}, constants = {} },
save = { options = {} },
stack = { pop = function() end },
input = { wasPressed = function() return false end },
modStatus = { available = {} },
})
local hasGbc = false
for _, row in ipairs(om.rows) do
if row.id == "gbcfx" then hasGbc = true end
end
check(not hasGbc, "Options menu omits GBC FX on Android")
love.system = prevSystem
-- === summary ===
S.finish()
+16
View File
@@ -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
+141
View File
@@ -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")
+129
View File
@@ -0,0 +1,129 @@
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
--
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
-- Tab / either Shift / gamepad Back. Self-contained; 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 Input = require("src.core.Input")
local S = require("tests.harness").suite("parity move swap")
local check, eq = S.check, S.eq
local function freshGame()
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
mon.moves = {
{ id = "TACKLE", pp = 35 },
{ id = "LEER", pp = 30 },
{ id = "HORN_ATTACK", pp = 25 },
{ id = "POISON_STING", pp = 35 },
}
return {
data = Data,
input = Input,
save = {
party = { mon },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
local function tapKey(battle, key)
Input:keypressed(key)
Input:step()
battle:update(0)
Input:keyreleased(key)
end
local function tapPad(battle, button)
Input:gamepadpressed(nil, button)
Input:step()
battle:update(0)
Input:gamepadreleased(nil, button)
end
-- Default Select sources all edge the logical select button.
do
Input:init()
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
Input:reset()
Input:keypressed(key)
Input:step()
check(Input:wasPressed("select"), key .. " maps to select")
end
Input:reset()
Input:gamepadpressed(nil, "back")
Input:step()
check(Input:wasPressed("select"), "gamepad back maps to select")
end
-- Fight menu: Select, move, Select swaps slots 1 and 2.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
tapKey(battle, "down")
eq(battle.moveIndex, 2, "cursor moved to slot 2")
tapKey(battle, "tab")
check(battle.moveSwapIndex == nil, "second Select clears the mark")
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
end
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapPad(battle, "back")
tapPad(battle, "dpdown")
tapPad(battle, "back")
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
end
-- A confirms a pending swap (bag-style), without starting the turn.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
tapKey(battle, "down")
tapKey(battle, "z") -- A
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
end
S.finish()
+57
View File
@@ -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()
+77
View File
@@ -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()
+125
View File
@@ -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()
+98
View File
@@ -0,0 +1,98 @@
-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81).
--
-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript:
-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent
-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player
-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 ->
-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle).
--
-- Self-contained; run via `luajit tests/parity_tower_heal_pad.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 tower heal pad")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story3.lua")
local tower = M.POKEMON_TOWER_5F
check(tower ~= nil and type(tower.onStep) == "function",
"POKEMON_TOWER_5F has an onStep heal-pad trigger")
local function cmds(rows)
local out = {}
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
return out
end
local function gameWith(flags)
return { save = { flags = flags or {} } }
end
local function owWith()
local ran = {}
return {
runner = {
isRunning = function() return false end,
run = function(_, rows) ran[#ran + 1] = rows end,
},
}, ran
end
-- ---- 1. pad coords + leave clears latch --------------------------------
do
local game = gameWith()
local ow, ran = owWith()
check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal")
check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal")
eq(#ran, 0, "no script off the pad")
end
do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals")
check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE")
eq(#ran, 1, "heal script runs once")
check(tower.onStep(game, ow, 11, 9),
"staying on the pad still consumes the step (BIT_NO_BATTLES)")
eq(#ran, 1, "heal does not re-fire while still on the pad")
check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false")
check(not game.save.flags.EVENT_IN_PURIFIED_ZONE,
"EVENT_IN_PURIFIED_ZONE resets off the pad")
check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again")
eq(#ran, 2, "a fresh visit runs the heal script again")
end
-- ---- 2. all four pad tiles trigger -------------------------------------
for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, cell[1], cell[2]),
("pad tile (%d,%d) heals"):format(cell[1], cell[2]))
eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2]))
end
-- ---- 3. heal sequence matches pokered order ----------------------------
do
local game = gameWith()
local ow, ran = owWith()
tower.onStep(game, ow, 10, 9)
local rows = ran[1]
check(rows ~= nil, "heal rows captured")
local sequence = table.concat(cmds(rows), ",")
eq(sequence, "heal_party,fade,wait,wait,fade,show_text",
"Tower pad sequence is heal → fade out → Delay3×2 → fade in → text")
eq(rows[2][3], "white", "fades out to white")
eq(rows[3][2], 3, "first Delay3 is 3 frames")
eq(rows[4][2], 3, "second Delay3 is 3 frames")
eq(rows[5][3], "white", "fades in from white")
eq(rows[6][2], "_PokemonTower5FPurifiedZoneText",
"shows the purified-zone text")
for _, row in ipairs(rows) do
check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad")
end
end
S.finish()
+7 -2
View File
@@ -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")
+49
View File
@@ -169,4 +169,53 @@ do
"tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)")
end
-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk ===
-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER
-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6).
-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell
-- distance 4. Cell math would engage, but pokered's unsigned screen-Y
-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so
-- the trainer must not challenge through that ledge (GitHub #76).
local ROUTE9_BUG_CATCHER = 7
local function freshRoute9(px, py, facing)
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Input:init()
OW.engaging = false
OW.emote = nil
Game.stack:push(OW, "ROUTE_9", px, py, facing)
local ow = Game.stack:top()
local trainer
for _, npc in ipairs(ow.npcs) do
if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end
end
return ow, trainer
end
do
local ow, trainer = freshRoute9(22, 6, "up")
check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2,
"Route 9 Bug Catcher stands at (22,2)")
eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)")
local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER)
eq(header and header.range, 4, "Bug Catcher sight range is 4")
for _ = 1, 10 do frame(ow) end
check(not ow.engaging,
"distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)")
-- same plateau, still in pixel range: distance 2 and 3 must engage
local ow2, t2 = freshRoute9(22, 4, "up")
local guard = 0
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
check(ow2.engaging and t2.cellY == 2,
"distance 2 on the upper plateau still engages")
local ow3 = freshRoute9(22, 3, "up")
guard = 0
while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end
check(ow3.engaging, "distance 3 on the upper plateau still engages")
end
S.finish()
+63
View File
@@ -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()
+241
View File
@@ -1072,6 +1072,81 @@ do
mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 })
check(hasText(mh, "Hit the enemy\n5 times!"),
"player multi-hit uses _MultiHitText")
-- #85: multi-hit replays PlayMoveAnimation per strike (pokered
-- GetPlayerAnimationType loop), interleaved with each HP drain
do
local anims, seq = 0, {}
for _, r in ipairs(mh.queue) do
if r.anim == "DOUBLESLAP" then
anims = anims + 1
seq[#seq + 1] = "anim"
elseif r.drain then
seq[#seq + 1] = "drain"
end
end
eq(anims, 5, "multi-hit queues the move animation once per hit")
eq(table.concat(seq, ","),
"anim,drain,anim,drain,anim,drain,anim,drain,anim,drain",
"multi-hit interleaves anim + drain per strike")
for _, r in ipairs(mh.queue) do
if r.anim == "DOUBLESLAP" then
check(r.hit ~= nil, "each multi-hit anim carries hit blink/sfx")
end
end
end
-- #85: crit / effectiveness reprint each strike (.moveDidNotMiss
-- before the GetPlayerAnimationType loop-back). DOUBLE_KICK is a
-- fixed 2-hit Fighting move -- SE vs Normal SNORLAX.
do
local function countText(b, s)
local n = 0
for _, it in ipairs(b.queue) do
if it.text and it.text:find(s, 1, true) then n = n + 1 end
end
return n
end
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local mhk = BattleState.newWild(Game, "SNORLAX", 40)
mhk.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max roll
mhk:performMove(mhk.player, mhk.enemy, { id = "DOUBLE_KICK", pp = 10 })
eq(countText(mhk, "It's super\neffective!"), 2,
"multi-hit prints effectiveness once per strike")
local seq = {}
for _, r in ipairs(mhk.queue) do
if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim"
elseif r.drain then seq[#seq + 1] = "drain"
elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se"
elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count"
end
end
eq(table.concat(seq, ","), "anim,drain,se,anim,drain,se,count",
"multi-hit queues SE text between strikes, count after")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local Events = require("src.mods.Events")
local savedE, savedH = Runtime.events, Runtime.hooks
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks)
local unsub = hooks:wrap("battle.crit", function() return true end)
local mhc = BattleState.newWild(Game, "SNORLAX", 40)
mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook
mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 })
eq(countText(mhc, "Critical hit!"), 2,
"multi-hit prints Critical hit! once per strike")
local cseq = {}
for _, r in ipairs(mhc.queue) do
if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim"
elseif r.drain then cseq[#cseq + 1] = "drain"
elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit"
elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se"
end
end
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se",
"crit then effectiveness follow each multi-hit drain")
unsub()
Runtime.install(savedE, savedH)
end
Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) }
local mh2 = BattleState.newWild(Game, "RATTATA", 5)
mh2.rng = mkseq({ 7, 0, 255, 255 })
@@ -1659,6 +1734,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 +2561,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 ==
@@ -2482,6 +2686,43 @@ do
tostring(c[3]), tostring(c[4])))
end
end
-- == issue #4: ledge hop countdown is fixed-step, not draw-rate ==
-- hopFrames used to decrement inside Player:draw, so >59fps ended the
-- arc early and <59fps stretched it. Countdown belongs in update().
do
local Player = require("src.world.Player")
local p = Player.new(Data, 5, 6, "down")
p.hopFrames, p.hopTotal = 32, 32
p:draw(0, 0)
p:draw(0, 0)
p:draw(0, 0)
eq(p.hopFrames, 32, "draw does not consume hopFrames")
for _ = 1, 10 do p:update() end
eq(p.hopFrames, 22, "ten fixed updates consume ten hopFrames")
for _ = 1, 22 do p:update() end
eq(p.hopFrames, 0, "hop expires after hopTotal fixed updates")
p:update()
eq(p.hopFrames, 0, "hopFrames stays at 0 once expired")
end
-- == issue #4: tile anim tick accumulates wall-clock into 60Hz steps ==
do
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.setSpinning(true)
local before = TileRenderer.spinBlurActive()
for _ = 1, 8 do TileRenderer.tick(1 / 60) end
check(before ~= TileRenderer.spinBlurActive(),
"tick(1/60) advances water/spinner clock at fixed 60Hz")
local mid = TileRenderer.spinBlurActive()
TileRenderer.tick(1 / 120)
eq(TileRenderer.spinBlurActive(), mid,
"sub-frame dt does not advance the tile anim clock")
TileRenderer.tick(1 / 120)
-- second half-frame completes one step; phase may or may not flip
-- (8-tick blur period), but the clock must have accepted the step
TileRenderer.setSpinning(false)
end
end
-- ================= BUGS.md batch: border-tree =================