mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a55c0b1a5 | |||
| e62ad3f2a4 | |||
| b40df05d2a | |||
| 5312e34997 | |||
| f0b53e28ab |
@@ -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?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,7 +48,7 @@ local KEY = {
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "rshift",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ===
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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 =================
|
||||
|
||||
Reference in New Issue
Block a user