This commit is contained in:
bryanthaboi
2026-07-24 08:45:16 -04:00
parent f0b53e28ab
commit 5312e34997
24 changed files with 858 additions and 102 deletions
+16 -15
View File
@@ -1,18 +1,19 @@
# Pokemon Gen 1 Recompilation Project # Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
hand-written Lua; game data and graphics are decoded from a ROM supplied by behavior are hand-written Lua; game data and graphics are decoded from a ROM
the player. supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu) SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly, 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 or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
content input. game content input.
The ROM is verified, used during import, and then released from memory. It is 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 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 ## 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 or drop it onto the window. Import takes a few seconds and the game starts
automatically. automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1 Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data. 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, 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 sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM. 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 ## Running From Source
Requires LÖVE 11.x. Place the ROM in the project folder and double-click Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
`Play-Mac.command` or `Play-Windows.bat`, or run: double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh ```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 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) > This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly. [pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
+22
View File
@@ -0,0 +1,22 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club", "data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant", "data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f", "data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f", "data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f", "data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f", "data.scripts.flavor.route_12_gate_2f",
+7 -4
View File
@@ -31,13 +31,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ species = species, forceOwned = true } }, -- 5 { species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6 { "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7 { "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8 -- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9 -- inside give_pokemon). Show the received text first so the
{ "set_flag", choseFlag }, -- 10 -- 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 -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12 { "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13 { "move_npc_to", 1, rivalBallX, 4 }, -- 13
+23 -8
View File
@@ -412,14 +412,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after) -- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow) onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster local poster = game.data.field.gameCornerPoster
if not poster then return end if poster then
local block = game.save.flags[poster.event] and poster.openBlock local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block) ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine -- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm) -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats) 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, end,
talk = { talk = {
-- the poster bg event: pressing A reveals the hidden switch -- the poster bg event: pressing A reveals the hidden switch
@@ -448,7 +457,16 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing -- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch -- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done) 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 if ow:trainerDefeated(npc) then
hideRocket()
done() done()
return return
end end
@@ -462,10 +480,7 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function() function()
local Commands = require("src.script.Commands") hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done() done()
end)) end))
end) end)
+94 -22
View File
@@ -258,6 +258,10 @@ local function makeBattler(data, mon, isPlayer, save)
} }
end 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 -- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON -- -- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only -- the same path makeBattler uses, but forced gray -- since this is only
@@ -832,7 +836,7 @@ function BattleState:computeMusicKind()
return "final" return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym" return "gym"
elseif self.kind == "trainer" then elseif self.kind == "trainer" or self.kind == "link" then
return "trainer" return "trainer"
end end
return "wild" return "wild"
@@ -894,6 +898,17 @@ function BattleState:enter()
self.showEnemyTrainer = false self.showEnemyTrainer = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) 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 end
if not self.ghost then if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm) -- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -950,20 +965,37 @@ function BattleState:exit()
end end
end end
-- An action the battler is locked into (bypasses the menu), or nil. -- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
function BattleState:lockedAction(battler) -- 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.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove 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 if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" } return { special = "trapping" }
end end
if battler.bideTurns then return { special = "bide" } end if battler.bideTurns then return { special = "bide" } end
if battler.rageMove then return battler.rageMove end -- held while the OPPONENT's trapping bit is set (live mirror so a
-- held in place while the OPPONENT's trapping move is running -- trap ended early by paralysis/faint frees the victim immediately)
-- (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
local opp = battler.isPlayer and self.enemy or self.player local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil and math.max(1, opp.trappingTurns) or nil
@@ -973,6 +1005,11 @@ function BattleState:lockedAction(battler)
return nil return nil
end 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() function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -1079,8 +1116,9 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false self.player.flinched, self.enemy.flinched = false, false
end end
-- locked multi-turn actions skip the menu entirely -- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
local locked = self:lockedAction(self.player) -- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
if locked then if locked then
self:resolveTurn(locked) self:resolveTurn(locked)
return return
@@ -1104,6 +1142,13 @@ function BattleState:update(dt)
end) end)
self:act(function() self:endOfTurn() end) self:act(function() self:endOfTurn() end)
elseif choice == "fight" then 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 if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages -- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name)) self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1514,6 +1559,9 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save) 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() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -2094,6 +2142,8 @@ function BattleState:executeAction(user, target, action)
local oldName = self.enemy.name local oldName = self.enemy.name
self.enemyIndex = action.index self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2574,8 +2624,20 @@ function BattleState:enemyMonFainted()
self.participants = {} self.participants = {}
if self.kind == "trainer" then if self.kind == "trainer" then
if self.enemyIndex < #self.enemyParty then -- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
self.enemyIndex = self.enemyIndex + 1 -- 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 -- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt) -- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex] local nextMon = self.enemyParty[self.enemyIndex]
@@ -2599,6 +2661,7 @@ function BattleState:enemyMonFainted()
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save) self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battle = self, side = self.sides[1],
@@ -2624,6 +2687,8 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2777,6 +2842,7 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player) self:restoreMimicked(self.player)
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save) self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -3739,7 +3805,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
end end
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.
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for -- Red's (or the old man's) back pic until "Go!"; it stays up for
@@ -3747,7 +3813,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.playerBackPic) local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0 local pad = imagePadBottom[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 16 + slide + sx, love.graphics.draw(img, 8 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3758,14 +3824,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local gs = self:growInScale(self.player) local gs = self:growInScale(self.player)
if gs then if gs then
-- the player-side AnimateSendingOutMon grow (after the poof, -- 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 if gs > 0 then
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx, love.graphics.draw(img, 8 + img:getWidth() * (1 - gs) + sx,
96 - (img:getHeight() - pad) * 2 * gs + sy, 96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs) 0, 2 * gs, 2 * gs)
end end
else else
self:drawBattlerPic(self.player, 16 + sx, self:drawBattlerPic(self.player, 8 + sx,
96 - (img:getHeight() - pad) * 2 + sy, 2) 96 - (img:getHeight() - pad) * 2 + sy, 2)
end end
end end
@@ -3820,16 +3886,22 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end end
-- trainer-battle party pokeball rows during the intro -- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack) local showIntroBalls = slide == 0 and (
and slide == 0 then (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) 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) self:drawBallRow(self.enemyParty, 64, 16, -8)
end end
if self.showPlayerBack then 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
end end
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
+19
View File
@@ -478,6 +478,25 @@ SaveData.addCoreMigration(2, function(save)
end end
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 -- ------- write
-- Game progress only; options are written separately via saveOptions. -- Game progress only; options are written separately via saveOptions.
+1 -1
View File
@@ -7,7 +7,7 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple) engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`) modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) 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) cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
} }
+49 -27
View File
@@ -36,18 +36,13 @@ local function makeRng(seed)
end end
end end
-- battler builder shared by both sides -- NO badge boosts, so both -- Battlers go through BattleState.makeBattler so pics get the same
-- machines compute identical stats -- 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 function mkBattler(data, mon, isPlayer)
local def = data.pokemon[mon.species] local BattleState = require("src.battle.BattleState")
local ok, img = pcall(love.graphics.newImage, return BattleState.makeBattler(data, mon, isPlayer, nil)
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,
}
end end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on -- 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.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false) self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty 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.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
@@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts)
return nil return nil
end 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 -- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg) local function decodeTheirAction(s, msg)
if msg.kind == "move" then if msg.kind == "move" then
@@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch) -- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then if myMsg.kind == "switch" then
local idx = myMsg.index local idx = myMsg.index
s:act(function() s:act(function() sendOutPlayer(s, myParty[idx]) end)
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
myAction = nil myAction = nil
end end
if theirSwitch then if theirSwitch then
s:act(function() s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
end end
s:act(function() s:act(function()
@@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do for _, mon in ipairs(myParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() s:act(function() sendOutPlayer(s, mon) end)
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
return return
end end
end end
@@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do for _, mon in ipairs(theirParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() s:act(function() sendOutEnemy(s, mon) end)
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
return return
end end
end end
+6
View File
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance -- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState" 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 -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+49 -2
View File
@@ -484,11 +484,51 @@ function Commands.heal_party(ctx)
end end
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 <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets -- 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 -- 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 -- 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). -- 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) function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created. -- 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: -- 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.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon) 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 if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
ctx.lastCheck = false ctx.lastCheck = false
return return
@@ -517,6 +558,11 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true dex.owned[species] = true
end end
ctx.lastCheck = true 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 end
function Commands.give_money(ctx, amount) function Commands.give_money(ctx, amount)
@@ -1066,7 +1112,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame", "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", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once" }) do "emote", "fade", "pan_camera", "play_once" }) do
local meta = Commands.meta[verb] or {} local meta = Commands.meta[verb] or {}
+8
View File
@@ -22,6 +22,14 @@ local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true 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 -- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps -- no-loader fallback for the ruleset row, same pair BattleState keeps
+33 -5
View File
@@ -13,13 +13,35 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON. -- 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) function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
local z = { local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), 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), 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 return z[3] and z or nil
end end
@@ -119,8 +141,11 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save) function ContinueInfo.new(title, save)
return setmetatable({ title = title, game = title.game, save = save }, -- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
ContinueInfo) return setmetatable({
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
end end
function ContinueInfo:update(dt) function ContinueInfo:update(dt)
@@ -184,8 +209,11 @@ function TitleState:openMenu()
love.event.quit() love.event.quit()
end end
end }) end })
game.stack:push(Menu.new(game, items, local th = #items * 2 + 2
{ tx = 0, ty = 0, tw = 13, 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 end
function TitleState:update(dt) function TitleState:update(dt)
+4 -1
View File
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
doorBlock = { bx = 2, by = 2, block = 5 } }, doorBlock = { bx = 2, by = 2, block = 5 } },
}, },
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both -- 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 = {
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
SEAFOAM_ISLANDS_B4F = { SEAFOAM_ISLANDS_B4F = {
surfBlocked = { { x = 7, y = 11, untilEvents = { surfBlocked = { { x = 7, y = 11, untilEvents = {
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
+24 -4
View File
@@ -322,6 +322,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- 16/18 gate exits), and the scripted door-mat walkout that follows -- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts -- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement() 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 -- 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 -- Transition is on top, so a stale camera would show the new map at
@@ -2669,8 +2675,11 @@ function OverworldState:onStepComplete()
elseif entry then elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it -- still standing on the warp we arrived through; do not re-trigger it
else 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) 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, w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
p.cellX, p.cellY, p.facing) p.cellX, p.cellY, p.facing)
end end
@@ -2751,9 +2760,11 @@ function OverworldState:runSpinnerMoves(moves, i)
local mv = moves[i] local mv = moves[i]
if not mv then if not mv then
self.player.spinning = false self.player.spinning = false
if not self:checkSpinner() and self.player.surfing then -- Scripted steps skip onStepComplete while they run; once the RLE
self:checkSeafoamCurrent() -- finishes, re-enter the normal landing pipeline so chained spinners,
end -- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on — same as pokered after simulated joypad.
self:onStepComplete()
return return
end end
self.player.spinning = true -- spin the sprite while sliding self.player.spinning = true -- spin the sprite while sliding
@@ -2926,6 +2937,9 @@ function OverworldState:checkSeafoamCurrent()
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
for _, c in ipairs(sf.forcedExit.coords) do for _, c in ipairs(sf.forcedExit.coords) do
if p.cellX == c.x and p.cellY == c.y then 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") require("src.core.Sound").play(Game.data, "Collision")
self:scriptMove(p, "up", c.y == 17 and 2 or 1) self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true return true
@@ -2947,6 +2961,12 @@ function OverworldState:checkSeafoamCurrent()
end end
for _, c in ipairs(active) do for _, c in ipairs(active) do
if p.cellX == c.x and p.cellY == c.y then 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) self:runSpinnerMoves(c.moves, 1)
return true return true
end end
+35
View File
@@ -215,6 +215,41 @@ do
SaveData.runMigrations(tagged) SaveData.runMigrations(tagged)
check(tagged.version == "blue", "an existing version tag is left untouched") 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 love.filesystem = realFS
end end
+99 -5
View File
@@ -476,7 +476,9 @@ check(title.version and title.version.path
"the shipped version ribbon loads") "the shipped version ribbon loads")
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon), -- 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 do
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
@@ -496,17 +498,109 @@ do
PaletteFX.setMode("redpp") PaletteFX.setMode("redpp")
GameVersion.set("blue") GameVersion.set("blue")
local zones = TitleState.sgbPalettes(title, game) local zones = TitleState.sgbPalettes(title, game)
check(zones and zones[2] and zones[2].colors == blueLogo1, local ribbon = zones and zones[2] and zones[2].colors
"Blue title ribbon zone uses ROM LOGO1 under RED++") 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") GameVersion.set("red")
zones = TitleState.sgbPalettes(title, game) zones = TitleState.sgbPalettes(title, game)
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1 local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
check(zones and zones[2] and zones[2].colors == gbcLogo1, ribbon = zones and zones[2] and zones[2].colors
"Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's") 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) GameVersion.set(prevVer)
PaletteFX.setMode(prevMode) PaletteFX.setMode(prevMode)
end 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 OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = { local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } }, field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
+44 -6
View File
@@ -7,6 +7,7 @@
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on -- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot -- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua). -- 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 package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data") local Data = require("src.core.Data")
@@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner") local ScriptRunner = require("src.script.ScriptRunner")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local NamingScreen = require("src.ui.NamingScreen")
local mapScripts = require("data.scripts.init") local mapScripts = require("data.scripts.init")
Game.data = Data Game.data = Data
@@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- pumps a script coroutine to completion, mashing A through any -- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's -- table for this frame (default: mash A through text/ask/naming)
-- runScript helper for the parcel/pokedex chain) local function runScript(script, pressFn)
local function runScript(script)
local r = ScriptRunner.new(Game, nil) local r = ScriptRunner.new(Game, nil)
r:run(script, {}) r:run(script, {})
local guard = 0 local guard = 0
while r:isRunning() and guard < 2000 do while r:isRunning() and guard < 4000 do
guard = guard + 1 guard = guard + 1
Input.pressed = { a = true } Input.pressed = pressFn and pressFn() or { a = true }
StateStack:update(1 / 60) StateStack:update(1 / 60)
r:update() r:update()
end end
@@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
"starter pick script completes") "starter pick script completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set") 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") 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 === -- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
@@ -107,4 +114,35 @@ check(repaired.OAKSLAB_POKEDEX1 == false
and repaired.OAKSLAB_POKEDEX2 == false, and repaired.OAKSLAB_POKEDEX2 == false,
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set") "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() S.finish()
+51
View File
@@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")), objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state") "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() S.finish()
+52
View File
@@ -79,6 +79,58 @@ do
"the queued trap anim row is attributed to the attacker") "the queued trap anim row is attributed to the attacker")
end 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 -- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
-- move executes, MoveHitTest runs, and only on a hit does the player's -- move executes, MoveHitTest runs, and only on a hit does the player's
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link -- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
+114
View File
@@ -0,0 +1,114 @@
-- Parity test: trainer next-mon after an AI switch must skip fainted slots.
--
-- Agatha (and any switchChance / switch AI) can withdraw mon 1, send mon 2,
-- then later return to mon 1. If mon 2 was KO'd in between, enemyMonFainted
-- used to do enemyIndex+1 and send the already-fainted Golbat back out.
-- The FIGHT menu reappears with an empty enemy HP bar; executeAction then
-- returns immediately on target.mon.hp <= 0 -- a softlock.
--
-- pokered's EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck scan the party
-- for the first mon with HP remaining (core.asm), not "current index + 1".
--
-- Self-contained; run via `luajit tests/parity_ai_switch_faint.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity ai switch faint")
local check, eq = S.check, S.eq
-- Minimal game stub matching what BattleState.newTrainer / enemyMonFainted
-- touch. SET style skips the SHIFT "change POKéMON?" ChoiceBox.
local function freshGame()
return {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 50) },
player = { name = "RED" },
inventory = {},
options = { battleStyle = "set" },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Drain act/say rows until the enemy swap act runs (or the queue empties).
local function pumpUntilSwap(b, limit)
limit = limit or 200
local n = 0
while #b.queue > 0 and n < limit do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then
b.nextInsert = 0
item.fn()
-- the send-out act replaces self.enemy; stop once a living mon is in
if b.enemy and b.enemy.mon and b.enemy.mon.hp > 0 then
return true
end
end
end
return b.enemy and b.enemy.mon and b.enemy.mon.hp > 0
end
-- #143: after AI-switch reorder, do not send a fainted slot.
do
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
check(#b.enemyParty >= 3, "Agatha has enough party slots for the scenario")
-- Simulate: switched to slot 2, KO'd it, switched back to slot 1, KO slot 1.
b.enemyParty[1].hp = 0
b.enemyParty[2].hp = 0
-- slots 3+ stay at full HP from construction
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
check(pumpUntilSwap(b), "a living reserve is sent out after the KO")
eq(b.enemyIndex, 3, "next index is the first living mon, not fainted slot 2")
check(b.enemy.mon.hp > 0, "sent-out mon has HP (no empty-bar softlock)")
check(b.enemy.mon == b.enemyParty[3], "sent-out battler is party slot 3")
check(b.result ~= "win", "battle continues while reserves remain")
end
-- Sequential KOs (no AI switch) still advance one slot at a time.
do
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1)
b.enemyParty[1].hp = 0
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
check(pumpUntilSwap(b), "youngster still sends the second mon")
eq(b.enemyIndex, 2, "without AI switch, first living mon is still index 2")
check(b.enemy.mon.hp > 0, "second mon is healthy")
end
-- No living reserves: victory even if enemyIndex is mid-party (AI left
-- earlier slots dead and the active one was not the last index).
do
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
for _, mon in ipairs(b.enemyParty) do mon.hp = 0 end
b.enemyIndex = 2
b.enemy.mon = b.enemyParty[2]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
eq(b.result, "win", "all-fainted party ends the trainer battle")
eq(b.afterQueue, "finish", "victory finishes after the queue")
end
S.finish()
+57
View File
@@ -0,0 +1,57 @@
-- Parity test: Red's bedroom SNES (#135).
--
-- pokered hidden_events.asm REDS_HOUSE_2F:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
-- PrintRedSNESText shows _RedBedroomSNESText ("{PLAYER} is playing the SNES!").
--
-- Self-contained; run via `luajit tests/parity_red_snes.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.REDS_HOUSE_2F) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local S = require("tests.harness").suite("parity red snes")
local check, eq = S.check, S.eq
check(Data.text._RedBedroomSNESText ~= nil, "_RedBedroomSNESText is extracted")
check(Data.text._RedBedroomSNESText:find("SNES", 1, true),
"_RedBedroomSNESText mentions the SNES")
local hooks = MapScripts.get("REDS_HOUSE_2F")
check(hooks and type(hooks.onInteract) == "function",
"REDS_HOUSE_2F registers onInteract for the SNES")
-- stub just enough of the overworld/game stack to exercise the hook
local pushed
local game = {
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = {
push = function(_, state) pushed = state end,
},
}
local ow = { player = { facing = "up" } }
eq(hooks.onInteract(game, ow, 0, 1), false,
"bedroom PC tile is not claimed by the SNES hook")
eq(hooks.onInteract(game, ow, 3, 6), false,
"spawn tile is not claimed by the SNES hook")
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES at (3,5) consumes the interact")
check(pushed ~= nil and pushed.pages ~= nil, "SNES interact pushes a TextBox")
local flat = table.concat(pushed.pages[1] or {}, "\n")
check(flat:find("SNES", 1, true) and flat:find("RED", 1, true),
"SNES TextBox shows the bedroom SNES line with the player name")
-- ANY_FACING: facing is irrelevant once the faced cell is (3,5)
ow.player.facing = "left"
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES still fires when the player faces it from the side")
S.finish()
+7 -2
View File
@@ -222,16 +222,21 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL")
"DUX post-trade script completes") "DUX post-trade script completes")
shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") 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 = local EEVEE_MAP, EEVEE_BALL =
"CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") 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, "Eevee joins the party")
eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE") eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE")
eq(Game.save.party[1].level, 25, "Eevee is level 25") 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") 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") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden")
check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned") check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned")
+43
View File
@@ -2445,6 +2445,49 @@ do
end end
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
end
-- ================= BUGS.md batch: ledge-shadow ================= -- ================= BUGS.md batch: ledge-shadow =================
do do
-- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block == -- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block ==