mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391
This commit is contained in:
@@ -3,9 +3,8 @@
|
||||
-- guide and SUPER_NERD2 garden nerd.
|
||||
--
|
||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||
-- YES/NO-branched flavor text is here.
|
||||
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
|
||||
-- is below.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
|
||||
local museumGuySteps = {
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up",
|
||||
"left",
|
||||
}
|
||||
|
||||
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
|
||||
local museumPlayerRle = {
|
||||
"NO",
|
||||
"up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
}
|
||||
|
||||
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
|
||||
local museumPreambles = {
|
||||
["27,18"] = { "up", "up" },
|
||||
["27,16"] = { "right", "left" },
|
||||
["26,17"] = { "up", "right" },
|
||||
["28,17"] = { "up", "left" },
|
||||
}
|
||||
|
||||
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
|
||||
-- pewterEscort.playerPlan in story5.lua
|
||||
local function museumPlan(x, y)
|
||||
local pre = museumPreambles[x .. "," .. y]
|
||||
if not pre then return nil end
|
||||
local buf = {}
|
||||
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
|
||||
buf[#buf] = pre[1]
|
||||
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||
local path = {}
|
||||
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||
local head = 0
|
||||
while path[head + 1] == "NO" do head = head + 1 end
|
||||
local tail = #path
|
||||
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||
local steps = {}
|
||||
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||
end
|
||||
|
||||
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
|
||||
local function museumEscortWalk(game, ow)
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
|
||||
if not plan then return false end
|
||||
local Music = require("src.core.Music")
|
||||
local t = text(game)
|
||||
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
|
||||
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
|
||||
local function walkOut()
|
||||
if not guy then return end
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if i > 4 then
|
||||
guy.cellX, guy.cellY = 27, 17
|
||||
guy.px, guy.py = 27 * 16, 17 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, "down", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
|
||||
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
|
||||
local function afterWalk()
|
||||
if guy then
|
||||
guy.stepFrames = nil
|
||||
guy.cellX, guy.cellY = 13, 8
|
||||
guy.px, guy.py = 13 * 16, 8 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "up"
|
||||
end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCitySuperNerd1ItsRightHereText
|
||||
or "It's right here!", walkOut)
|
||||
end
|
||||
|
||||
local function lockstep()
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local ps = plan.steps[i]
|
||||
if not ps then
|
||||
afterWalk()
|
||||
return
|
||||
end
|
||||
local gs = museumGuySteps[head + i]
|
||||
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||
ow:scriptMove(ow.player, ps, 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
local function headTick()
|
||||
h = h + 1
|
||||
if h > head then lockstep(); return end
|
||||
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
|
||||
end
|
||||
headTick()
|
||||
else
|
||||
lockstep()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
|
||||
talk = {
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
|
||||
-- checked out the museum; YES -> fossils comment, NO -> "you have
|
||||
-- to go" (which in pokered also kicks off the escort script).
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
|
||||
-- fossils comment, NO -> "you have to go" and the museum escort
|
||||
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
|
||||
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
||||
else
|
||||
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
||||
or "Really?\nYou absolutely\nhave to go!", done)
|
||||
or "Really?\nYou absolutely\nhave to go!", function()
|
||||
museumEscortWalk(game, ow)
|
||||
if done then done() end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -288,6 +288,7 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
|
||||
@@ -263,6 +263,7 @@ return {
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if not rival then return false end
|
||||
local rows = {
|
||||
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
|
||||
@@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = {
|
||||
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
||||
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
||||
M.BLUES_HOUSE = {
|
||||
-- scripts/BluesHouse.asm:12-16
|
||||
onEnter = function(game, ow)
|
||||
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
|
||||
end,
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" },
|
||||
|
||||
@@ -87,6 +87,18 @@ end
|
||||
M.PALLET_TOWN = {
|
||||
talk = require("data.scripts.pallet_town").talk,
|
||||
escort = escort,
|
||||
-- scripts/PalletTown.asm:133-144
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
|
||||
and not f.EVENT_DAISY_WALKING then
|
||||
f.EVENT_DAISY_WALKING = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
|
||||
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
|
||||
end
|
||||
end,
|
||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||
-- PalletTownPikachuBattleScript).
|
||||
|
||||
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
|
||||
+20
-10
@@ -583,7 +583,8 @@ end
|
||||
local function stampOT(save, mon)
|
||||
save.player.id = save.player.id or math.random(0, 65535)
|
||||
mon.ot = mon.ot or save.player.name
|
||||
mon.otId = mon.otId or save.player.id
|
||||
-- engine/battle/experience.asm:69
|
||||
if not mon.traded then mon.otId = mon.otId or save.player.id end
|
||||
end
|
||||
BattleState.stampOT = stampOT
|
||||
|
||||
@@ -5923,10 +5924,16 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(Font.BORDER.h, 32, 96)
|
||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
-- engine/battle/misc.asm:37
|
||||
for i = 1, 4 do
|
||||
local mv = self.player.curMoves[i]
|
||||
if mv then
|
||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
else
|
||||
Font.draw("-", 48, 96 + i * 8)
|
||||
end
|
||||
end
|
||||
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
||||
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
|
||||
@@ -5953,13 +5960,16 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
end
|
||||
elseif self.phase == "mimicSelect" then
|
||||
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
|
||||
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
|
||||
-- single-spaced from (2,8), cursor at column 1
|
||||
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
|
||||
-- 16x6 box at (0,7), names from (2,8), cursor at column 1
|
||||
Font.drawBox(0, 7, 16, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, m in ipairs(self.mimicMoves) do
|
||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||
-- engine/battle/misc.asm:37
|
||||
for i = 1, 4 do
|
||||
local m = self.mimicMoves[i]
|
||||
local def = m and self.data.moves[m.id]
|
||||
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
|
||||
16, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
|
||||
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
hitSfx = { sound = "Damage", pitch = 0x20 }
|
||||
end
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
|
||||
-- the move has an added effect (#354)
|
||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
|
||||
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
|
||||
local hitFx = { sfx = hitSfx,
|
||||
animType = user.isPlayer and (added and 5 or 4)
|
||||
or (added and 2 or 1),
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
blink = target }
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
|
||||
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
|
||||
end
|
||||
end
|
||||
|
||||
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
|
||||
-- APPENDED after whatever the script already loaded rather than replacing it,
|
||||
-- and they always land on the same two fixed tile ids.
|
||||
--
|
||||
-- (pokegold's jumptable has these two labels the other way round from the
|
||||
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
|
||||
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
|
||||
-- is what a script actually writes.)
|
||||
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
|
||||
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
|
||||
function Runner:loadBattlerGfx(rows)
|
||||
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
|
||||
local slot = 1
|
||||
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
|
||||
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
|
||||
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
|
||||
battler = "player", rows = rows }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
|
||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
|
||||
battler = "enemy", rows = rows }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
|
||||
battler = "player", rows = rows }
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
+80
-31
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
|
||||
EFFECT_PARALYZE_HIT = "paralyze",
|
||||
EFFECT_SLEEP_HIT = "sleep",
|
||||
EFFECT_CONFUSE_HIT = "confuse",
|
||||
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
|
||||
}
|
||||
|
||||
local function rand(random, n)
|
||||
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
|
||||
if self.moveEvent then self.moveEvent.missed = true end
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:3615
|
||||
Battle.AI_FAIL_STATUSES = {
|
||||
sleep = true, poison = true, toxic = true, paralyze = true,
|
||||
}
|
||||
|
||||
-- engine/battle/effect_commands.asm:3615
|
||||
function Battle:aiRandomFail(attacker, defender)
|
||||
if self:sideOf(attacker) ~= "enemy" then return false end
|
||||
if self:volatile(defender).lockOn then return false end
|
||||
return rand(self.random, 256) < 64
|
||||
end
|
||||
|
||||
-- One attack, start to finish.
|
||||
function Battle:useMove(attacker, defender, moveId)
|
||||
local move = self:findMove(attacker, moveId)
|
||||
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
if charge and not charging then
|
||||
state.chargeMove = moveId
|
||||
state.vanished = charge.vanish or nil
|
||||
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
|
||||
-- keyed by effect cannot tell them apart and DIG announced itself with
|
||||
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
|
||||
-- effect: `cp DIG` and then the burrow text.
|
||||
-- engine/battle/effect_commands.asm:5458
|
||||
if self.moveEvent then self.moveEvent.animParam = 1 end
|
||||
-- BattleCommand_Charge picks the line off the MOVE, not the shared
|
||||
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
|
||||
local text = charge.text
|
||||
if moveId == "DIG" then text = "%s dug a hole!" end
|
||||
self:emit({ kind = "message", text = text:format(name) })
|
||||
@@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
-- Defense Curl arms Rollout as well as raising Defense.
|
||||
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
|
||||
|
||||
-- Stat changes: the primary ones always land, the *_HIT ones roll the
|
||||
-- move's effect chance after a hit that connected.
|
||||
--
|
||||
-- A refused primary change is a failure the cart detects BEFORE its anim
|
||||
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
|
||||
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
|
||||
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
|
||||
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
|
||||
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
|
||||
-- after `moveanim` (AttackDownHit), so the animation has already played.
|
||||
-- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
|
||||
-- :4380-4400); the *_HIT twins animate first and must stay unmarked.
|
||||
local change = Effects.STAT_CHANGES[def.effect]
|
||||
if change then
|
||||
local target = change[3] == "self" and attacker or defender
|
||||
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
||||
-- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
|
||||
local misted = target ~= attacker and (change[2] or 0) < 0
|
||||
and self:volatile(target).mist
|
||||
if not misted and change[3] == "foe"
|
||||
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
|
||||
and self:aiRandomFail(attacker, target) then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
||||
then
|
||||
self:markMissed()
|
||||
end
|
||||
@@ -1853,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
local record = Battle.moveEffectRecordFor(self.data, def.effect)
|
||||
local status = record and record.kind == "primary" and record.status or nil
|
||||
if status and (def.power or 0) == 0 then
|
||||
-- Every status command's already-statused / immune arm ends on
|
||||
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
|
||||
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
|
||||
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
|
||||
-- success path (:3752), and the effect scripts carry no moveanim of their
|
||||
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
|
||||
-- branch below is the opposite case: that move already hit and already
|
||||
-- animated, so a refused secondary must leave the event unmarked.
|
||||
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
|
||||
-- A refused primary status is a failed move (effect_commands.asm:3748,
|
||||
-- :6656); a refused secondary already animated and stays unmarked (:3752).
|
||||
if Battle.AI_FAIL_STATUSES[status]
|
||||
and self:aiRandomFail(attacker, defender) then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
elseif not self:applyStatus(defender, status, attacker) then
|
||||
self:markMissed()
|
||||
end
|
||||
else
|
||||
local secondary = record and record.kind == "secondary"
|
||||
and record.status or nil
|
||||
if secondary and (defender.hp or 0) > 0 then
|
||||
-- engine/battle/effect_commands.asm:6325
|
||||
if secondary and (defender.hp or 0) > 0
|
||||
and not self:safeguarded(defender) then
|
||||
local chance = def.effectChance or 0
|
||||
if chance > 0 and rand(self.random, 100) < chance then
|
||||
self:applyStatus(defender, secondary, attacker)
|
||||
@@ -2425,6 +2440,15 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||
text = self:monName(attacker) .. "'s DEFENSE rose!" })
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/safeguard.asm:1
|
||||
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
|
||||
local side = self.screens[self:sideOf(attacker)]
|
||||
if (side.safeguard or 0) > 0 then return fail(self) end
|
||||
side.safeguard = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s covered by a veil!" })
|
||||
end
|
||||
|
||||
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
|
||||
-- one body. A non-Ghost user trades a stage of Speed for one each of Attack
|
||||
-- and Defense, refused only when BOTH raises are already capped; a Ghost
|
||||
@@ -2910,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
|
||||
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:6325
|
||||
function Battle:safeguarded(mon)
|
||||
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
||||
end
|
||||
|
||||
-- `source` is the battler that inflicted it, carried only so
|
||||
-- battle.status_inflicted can name it the way Gen 1's does.
|
||||
function Battle:applyStatus(mon, status, source)
|
||||
@@ -2917,7 +2946,14 @@ function Battle:applyStatus(mon, status, source)
|
||||
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
|
||||
-- in the volatile beside the major status, so a confused mon can still be
|
||||
-- burned and a switch shakes the confusion off.
|
||||
if status == "confuse" then return self:applyConfusion(mon) end
|
||||
if status == "confuse" then return self:applyConfusion(mon, nil, source) end
|
||||
-- engine/battle/effect_commands.asm:6338
|
||||
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||
and self:safeguarded(mon) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||
return false
|
||||
end
|
||||
-- One major status at a time.
|
||||
if mon.status then
|
||||
self:emit({ kind = "message",
|
||||
@@ -2953,8 +2989,15 @@ end
|
||||
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
|
||||
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
|
||||
|
||||
function Battle:applyConfusion(mon, turns)
|
||||
function Battle:applyConfusion(mon, turns, source)
|
||||
if (mon.hp or 0) <= 0 then return false end
|
||||
-- engine/battle/effect_commands.asm:6338
|
||||
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||
and self:safeguarded(mon) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||
return false
|
||||
end
|
||||
local state = self:volatile(mon)
|
||||
if (state.substitute or 0) > 0 then return false end
|
||||
local held = self:heldEffect(mon, "confuse")
|
||||
@@ -4434,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = {
|
||||
function Battle:tickScreens()
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local screens = self.screens[side]
|
||||
for _, field in ipairs({ "lightScreen", "reflect" }) do
|
||||
for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
|
||||
if (screens[field] or 0) > 0 then
|
||||
screens[field] = screens[field] - 1
|
||||
if screens[field] <= 0 then
|
||||
screens[field] = nil
|
||||
self:emit({ kind = "message",
|
||||
text = Battle.SCREEN_SIDE_LABEL[side]
|
||||
.. Battle.SCREEN_FALL_TEXT[field] })
|
||||
if field == "safeguard" then
|
||||
-- engine/battle/core.asm:1527
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
|
||||
else
|
||||
self:emit({ kind = "message",
|
||||
text = Battle.SCREEN_SIDE_LABEL[side]
|
||||
.. Battle.SCREEN_FALL_TEXT[field] })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+3
-3
@@ -387,9 +387,9 @@ end
|
||||
|
||||
-- overworld map theme; onBike/surfing override outdoor themes with the
|
||||
-- bike/surf songs and restore the map theme when they end
|
||||
function Music.playMap(data, mapId, onBike, surfing, fade)
|
||||
local song = data and data.audio and data.audio.mapSongs
|
||||
and mapId and data.audio.mapSongs[mapId] or nil
|
||||
function Music.playMap(data, mapId, onBike, surfing, fade, song)
|
||||
song = song or (data and data.audio and data.audio.mapSongs
|
||||
and mapId and data.audio.mapSongs[mapId]) or nil
|
||||
state.mapSong = song
|
||||
state.onBike = not not onBike
|
||||
state.surfing = not not surfing
|
||||
|
||||
@@ -358,42 +358,33 @@ end
|
||||
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
|
||||
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
|
||||
-- (constants/script_constants.asm).
|
||||
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
|
||||
-- routine that prints it is engine code and no script bytecode points at the
|
||||
-- string, so the extractor never reaches it.
|
||||
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
|
||||
-- the extractor never reaches it.
|
||||
local CONTEST_NICKNAME_PROMPT =
|
||||
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
|
||||
|
||||
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
|
||||
function Specials.askNickname(vm, mon)
|
||||
nameMon(vm, mon.species)
|
||||
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
||||
if coroutine.yield({ kind = "yesorno" }) then
|
||||
-- InitNickname (engine/pokemon/move_mon.asm:1787)
|
||||
local h = hooks(vm)
|
||||
local name = h.renameMon and Specials.block(vm, function(done)
|
||||
h.renameMon(mon, done, { blank = true })
|
||||
end)
|
||||
-- _InitString's blank test (home/string.asm:6-30)
|
||||
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
||||
end
|
||||
end
|
||||
|
||||
H.CheckPartyFullAfterContest = function(vm)
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local result, mon =
|
||||
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
|
||||
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
|
||||
-- mon that joined the party and the one that went to the box -- and nowhere
|
||||
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
|
||||
-- in wContestMon, so this is the only place the player is ever asked.
|
||||
-- GetPokemonName runs first, which is what {STRBUF} reads.
|
||||
-- GiveANickname_YesNo runs on both contest arms, party and box
|
||||
if mon and result ~= BugContest.NO_CATCH then
|
||||
nameMon(vm, mon.species)
|
||||
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
|
||||
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
|
||||
-- left standing.
|
||||
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
||||
if coroutine.yield({ kind = "yesorno" }) then
|
||||
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
|
||||
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
|
||||
-- copies the species name back over an empty entry -- so a cancelled
|
||||
-- keyboard is the same as answering NO.
|
||||
local h = hooks(vm)
|
||||
local name = h.renameMon and Specials.block(vm, function(done)
|
||||
h.renameMon(mon, done, { blank = true })
|
||||
end)
|
||||
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
|
||||
-- spaces followed by a null". The keyboard's blank cells are real
|
||||
-- typeable characters, so an all-space entry has to be discarded the
|
||||
-- same way an empty one is, not stored as a name of spaces.
|
||||
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
||||
end
|
||||
Specials.askNickname(vm, mon)
|
||||
end
|
||||
answer(vm, result)
|
||||
end
|
||||
@@ -1005,7 +996,7 @@ end
|
||||
-- check is transcribed here rather than left to the screen, because its two
|
||||
-- refusals are TEXT and the script has to see them before the machine opens:
|
||||
-- no coins at all, or no COIN_CASE to hold them.
|
||||
local COIN_CASE = 0x47 -- constants/item_constants.asm
|
||||
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
|
||||
|
||||
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
|
||||
local NO_COINS_TEXT = "You have no coins."
|
||||
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
|
||||
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
|
||||
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
|
||||
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
|
||||
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
|
||||
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
|
||||
-- WaitForLinkedFriend and lands on .FriendNotReady
|
||||
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
|
||||
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
|
||||
{ "TradeCenter", nil, "link cable: no trade room" },
|
||||
{ "Colosseum", nil, "link cable: no battle room" },
|
||||
|
||||
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
|
||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||
if self.givePokeFn then
|
||||
self.givePokeFn(species, level, item)
|
||||
local mon = self.givePokeFn(species, level, item)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||
if mon and trainer == 0 then
|
||||
Specials.askNickname(self, mon)
|
||||
end
|
||||
end
|
||||
elseif op == "checkpoke" then
|
||||
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
|
||||
|
||||
+124
-38
@@ -14,6 +14,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local DexEntryMenu = {}
|
||||
DexEntryMenu.__index = DexEntryMenu
|
||||
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
|
||||
return speciesOrOpts, false
|
||||
end
|
||||
|
||||
local function ownedFor(game, def, forceOwned)
|
||||
return forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
|
||||
end
|
||||
|
||||
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
|
||||
local function descPages(game, def, forceOwned)
|
||||
local e = def.dexEntry or {}
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
if not text then return nil end
|
||||
local pages = {}
|
||||
for chunk in (text .. "\f"):gmatch("(.-)\f") do
|
||||
local lines = {}
|
||||
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
|
||||
if #lines > 0 then pages[#pages + 1] = lines end
|
||||
end
|
||||
if #pages == 0 then return nil end
|
||||
local last = pages[#pages]
|
||||
last[#last] = last[#last] .. "."
|
||||
return pages
|
||||
end
|
||||
|
||||
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
|
||||
local frameCache = {}
|
||||
local function frameSheet(game)
|
||||
local fx = game.data.field and game.data.field.overworldFx
|
||||
local def = fx and fx.pokedexFrame
|
||||
local path = def and def.path
|
||||
if not path then return nil end
|
||||
local hit = frameCache[path]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
if not ok or not img then
|
||||
frameCache[path] = false
|
||||
return nil
|
||||
end
|
||||
local iw, ih = img:getDimensions()
|
||||
local quads = {}
|
||||
for i = 0, 17 do
|
||||
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||
math.floor(i / 3) * 8, 8, 8, iw, ih)
|
||||
end
|
||||
frameCache[path] = { img = img, quads = quads }
|
||||
return frameCache[path]
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:601
|
||||
local DIVIDER = {
|
||||
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
|
||||
}
|
||||
|
||||
function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned,
|
||||
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
-- pcall's second return has to survive the guard (#307)
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
self.page = 1
|
||||
local pages = descPages(game, self.def, forceOwned)
|
||||
self.pageCount = pages and #pages or 1
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
@@ -57,6 +115,11 @@ end
|
||||
function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:245
|
||||
if self.page < (self.pageCount or 1) then
|
||||
self.page = self.page + 1
|
||||
return
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
@@ -64,64 +127,87 @@ end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor)
|
||||
self.spriteTrueColor, self.page)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
-- engine/menus/pokedex.asm:399
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
page = page or 1
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local frame = frameSheet(game)
|
||||
if frame then
|
||||
local function tile(code, tx, ty)
|
||||
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
|
||||
end
|
||||
-- engine/menus/pokedex.asm:418
|
||||
for tx = 1, 18 do
|
||||
tile(0x64, tx, 0)
|
||||
tile(0x6f, tx, 17)
|
||||
end
|
||||
for ty = 1, 16 do
|
||||
tile(0x66, 0, ty)
|
||||
tile(0x67, 19, ty)
|
||||
end
|
||||
tile(0x63, 0, 0)
|
||||
tile(0x65, 19, 0)
|
||||
tile(0x6c, 0, 17)
|
||||
tile(0x6e, 19, 17)
|
||||
-- engine/menus/pokedex.asm:445
|
||||
for tx = 0, 19 do
|
||||
tile(DIVIDER[tx + 1], tx, 9)
|
||||
end
|
||||
end
|
||||
if sprite then
|
||||
local y = math.max(0, 60 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, 8, y)
|
||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
||||
-- bleed into the next real frame.
|
||||
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
|
||||
local w, h = sprite:getDimensions()
|
||||
local x = 8 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 8 + (7 - h / 8) * 8
|
||||
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
|
||||
-- the unshaded pass needs the pic bounds (#350)
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
-- engine/menus/pokedex.asm:454
|
||||
Font.draw(def.name, 72, 16)
|
||||
local e = def.dexEntry or {}
|
||||
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
|
||||
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
|
||||
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
|
||||
Font.draw(e.kind or "?", 72, 20)
|
||||
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
|
||||
Font.draw(e.kind or "?", 72, 32)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
||||
-- height/weight print only once owned, like the description
|
||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||
-- height, weight, or description")
|
||||
-- engine/menus/pokedex.asm:478
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||
16, 64)
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
-- engine/menus/pokedex.asm:449, numbers only once owned
|
||||
if owned and e.heightFt then
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
if e.heightM then
|
||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
|
||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
|
||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
|
||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
|
||||
end
|
||||
end
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
if text then
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if y > 132 then break end
|
||||
Font.draw(line, 8, y)
|
||||
y = y + 10
|
||||
local pages = descPages(game, def, forceOwned)
|
||||
if pages then
|
||||
-- engine/menus/pokedex.asm:568
|
||||
local lines = pages[page] or pages[#pages]
|
||||
for i, line in ipairs(lines) do
|
||||
Font.draw(line, 8, 72 + i * 16)
|
||||
end
|
||||
-- home/text.asm:245
|
||||
if page < #pages then
|
||||
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||
end
|
||||
else
|
||||
Font.draw(Strings("Data unknown."), 8, y)
|
||||
Font.draw(Strings("Data unknown."), 8, 88)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HallOfFame = require("src.ui.HallOfFame")
|
||||
|
||||
local LeaguePC = {}
|
||||
LeaguePC.__index = LeaguePC
|
||||
LeaguePC.isOpaque = true
|
||||
|
||||
-- constants/pokemon_data_constants.asm:65
|
||||
local CAPACITY = 50
|
||||
|
||||
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
|
||||
function LeaguePC:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local mon = self:currentMon()
|
||||
local c = mon and P.monPal(game.data, mon.species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
function LeaguePC.new(game, onDone)
|
||||
local self = setmetatable({}, LeaguePC)
|
||||
self.game = game
|
||||
self.onDone = onDone
|
||||
self.teams = (game.save and game.save.hallOfFame) or {}
|
||||
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
|
||||
self.monIndex = 1
|
||||
self.sprites = {}
|
||||
self.spriteTrueColor = {}
|
||||
self:loadMon()
|
||||
return self
|
||||
end
|
||||
|
||||
function LeaguePC:currentMon()
|
||||
local team = self.teams[self.teamIndex]
|
||||
return team and team[self.monIndex] or nil
|
||||
end
|
||||
|
||||
function LeaguePC:loadMon()
|
||||
local mon = self:currentMon()
|
||||
if not mon then return end
|
||||
local species = mon.species
|
||||
if self.sprites[species] == nil then
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprites[species] = ok and img or false
|
||||
self.spriteTrueColor[species] = (ok and img and trueColor) or false
|
||||
end
|
||||
require("src.core.Sound").playCry(self.game.data, species)
|
||||
end
|
||||
|
||||
function LeaguePC:close()
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function LeaguePC:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("b") then
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("a") then
|
||||
if not self:currentMon() then
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
local team = self.teams[self.teamIndex]
|
||||
if self.monIndex < #team then
|
||||
self.monIndex = self.monIndex + 1
|
||||
elseif self.teamIndex < #self.teams then
|
||||
self.teamIndex = self.teamIndex + 1
|
||||
self.monIndex = 1
|
||||
else
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
self:loadMon()
|
||||
end
|
||||
end
|
||||
|
||||
function LeaguePC:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local mon = self:currentMon()
|
||||
if not mon then return end
|
||||
local img = self.sprites[mon.species]
|
||||
if img then
|
||||
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
|
||||
local w, h = img:getDimensions()
|
||||
local x = 96 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 40 + (7 - h / 8) * 8
|
||||
love.graphics.draw(img, x, y)
|
||||
if self.spriteTrueColor[mon.species] then
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||
end
|
||||
end
|
||||
-- engine/movie/hall_of_fame.asm:159
|
||||
HallOfFame.drawMonInfo(self, mon)
|
||||
-- engine/menus/league_pc.asm:102
|
||||
Font.drawBox(0, 13, 20, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
|
||||
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return LeaguePC
|
||||
+11
-1
@@ -79,7 +79,16 @@ end
|
||||
function NamingScreen:enter()
|
||||
if self.presets and #self.presets > 0 then
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = { { label = Strings("NEW NAME") } }
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:1
|
||||
self.choosing = true
|
||||
self.isOpaque = false
|
||||
local items = { {
|
||||
label = Strings("NEW NAME"),
|
||||
onSelect = function()
|
||||
self.choosing = nil
|
||||
self.isOpaque = nil
|
||||
end,
|
||||
} }
|
||||
for _, preset in ipairs(self.presets) do
|
||||
table.insert(items, {
|
||||
label = preset,
|
||||
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
|
||||
end
|
||||
|
||||
function NamingScreen:draw()
|
||||
if self.choosing then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+32
-15
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
|
||||
-- the player's current location (guard: overworld may not be running)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
-- engine/items/town_map.asm:347
|
||||
do
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
local ok, img = pcall(love.graphics.newImage, red and red.image)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
img:getDimensions())
|
||||
end
|
||||
end
|
||||
self.sel = 1
|
||||
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
|
||||
-- fly destination (PALLET_TOWN), never the player's current town (#795).
|
||||
@@ -345,18 +359,16 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- the player's current location blinks (slow phase). Paint it with a
|
||||
-- palette-safe DARK shade (red 0), not red: this screen composites through
|
||||
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
|
||||
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
|
||||
-- {165,214,255}, the exact light-blue used for the water and the town-square
|
||||
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
|
||||
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.playerSheet then
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
||||
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
|
||||
@@ -389,11 +401,16 @@ function TownMap:draw()
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
-- palette-safe dark, same red-channel shade-remap reason as the primary
|
||||
-- grid path above (#152); stale-asset builds hit this fallback square
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerSheet then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad,
|
||||
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
if selected and self.blink % 16 < 10 then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+34
-38
@@ -49,6 +49,9 @@ BattleState.isOpaque = true
|
||||
-- the victory jingle can keep looping through the post-win prompts.
|
||||
local MESSAGE_FRAMES = 48
|
||||
|
||||
-- engine/battle/effect_commands.asm:6661
|
||||
local MOVE_DELAY_FRAMES = 40
|
||||
|
||||
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
|
||||
local HM_MOVES = {
|
||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
||||
@@ -239,15 +242,8 @@ function BattleState.new(game, opts)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.picCache = {}
|
||||
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
|
||||
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
|
||||
-- MonFaintedAnimation both clear the box and neither puts anything back: it
|
||||
-- stays blank until something DRAWS a pic into it, which on the cart is only
|
||||
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
|
||||
-- Without this latch the pic came back the instant the animation let go of
|
||||
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
|
||||
-- popped back up for its own faint line. See stepAnim for why it is latched
|
||||
-- at those two moments rather than off the runner's own last frame.
|
||||
-- Which side's pic box stays EMPTY until a send-out redraws it: a catch
|
||||
-- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
|
||||
self.picHidden = { player = false, enemy = false }
|
||||
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
|
||||
-- from opposite sides before the first message.
|
||||
@@ -1050,10 +1046,10 @@ function BattleState:afterAnimFor(side)
|
||||
return "ANIM_PLAYER_DAMAGE"
|
||||
end
|
||||
|
||||
function BattleState:animForMove(moveId, side)
|
||||
function BattleState:animForMove(moveId, side, param)
|
||||
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
|
||||
local started = self:startAnim(key, {
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true,
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
|
||||
})
|
||||
if started then
|
||||
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
|
||||
@@ -1091,14 +1087,22 @@ function BattleState:animForId(idName, side, param)
|
||||
})
|
||||
end
|
||||
|
||||
-- data/moves/animations.asm:379
|
||||
function BattleState:latchCaughtPic()
|
||||
local anim = self.anim
|
||||
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
|
||||
and self.ballThrow and self.ballThrow.caught then
|
||||
self.picHidden.enemy = true
|
||||
end
|
||||
end
|
||||
|
||||
-- One logic frame of a running animation. B cuts it short, the way holding B
|
||||
-- pages a text box.
|
||||
function BattleState:stepAnim(input)
|
||||
if not self.anim then return end
|
||||
if input and (input:wasPressed("b") or input:wasPressed("start")) then
|
||||
-- Cut short: the BG effects never reached their own last step, so the
|
||||
-- tilemap is whatever they had got to and nothing is latched -- the
|
||||
-- explicit latches (a catch) are the only ones that survive a skip.
|
||||
-- Cut short: only the explicit latches (a caught mon) survive a skip.
|
||||
self:latchCaughtPic()
|
||||
self.anim = nil
|
||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||
-- of the move should not drop the hit shake that follows it.
|
||||
@@ -1106,6 +1110,7 @@ function BattleState:stepAnim(input)
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
if not self.anim:step() then
|
||||
self:latchCaughtPic()
|
||||
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
|
||||
-- the OAM outlives the script, so keep the runner for drawing too.
|
||||
if not self.anim.keepSprites then self.anim = nil end
|
||||
@@ -1391,16 +1396,14 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
if event.text then
|
||||
self.message = event.text
|
||||
-- Lines that must not hold the queue for A/B:
|
||||
-- move UsedMoveText -> text_end, then moveanim
|
||||
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
|
||||
-- box's WaitPressAorB is the real hold
|
||||
-- experience keeps the wait: _ExpPointsText ends in `prompt`
|
||||
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
|
||||
-- so the bar crawls under the line and A dismisses it before the battle
|
||||
-- can end.
|
||||
-- move/level lines do not hold for A/B (battle.asm:336-343); experience
|
||||
-- keeps the wait (common_1.asm:1660-1665).
|
||||
if event.kind == "move" or event.kind == "level" then
|
||||
self.messageTimer = 0
|
||||
-- engine/battle/effect_commands.asm:1958-1961
|
||||
if event.kind == "move" and event.missed then
|
||||
self.messageDelay = MOVE_DELAY_FRAMES
|
||||
end
|
||||
else
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
@@ -1422,17 +1425,12 @@ function BattleState:advanceQueue()
|
||||
if event.waitSfx then self.waitSfx = event.sfx end
|
||||
end
|
||||
end
|
||||
-- The move's own animation plays over its "used X!" line, which is where
|
||||
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
|
||||
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
|
||||
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
|
||||
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
|
||||
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
|
||||
-- event.missed on every wAttackMissed path.
|
||||
-- engine/battle/effect_commands.asm:1958: a missed move burns the delay
|
||||
-- and plays nothing; the after-anim chain is animForMove / stepAnim's.
|
||||
if event.kind == "move" and not event.missed then
|
||||
self.afterAnimPlayed = nil
|
||||
self.pendingAfterAnim = nil
|
||||
if not self:animForMove(event.move, event.side) then
|
||||
if not self:animForMove(event.move, event.side, event.animParam) then
|
||||
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
|
||||
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
||||
local options = self.game and self.game.options
|
||||
@@ -1854,6 +1852,11 @@ function BattleState:update(_dt)
|
||||
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
|
||||
-- before any PromptButton wait so the bar does not sit frozen until A.
|
||||
if self:stepExpAnim() then return end
|
||||
-- engine/battle/effect_commands.asm:6661
|
||||
if (self.messageDelay or 0) > 0 then
|
||||
self.messageDelay = self.messageDelay - 1
|
||||
return
|
||||
end
|
||||
if self.messageTimer > 0 then
|
||||
if self.tutorial then
|
||||
-- PromptButton waits for the button; the tutorial cannot press it, so
|
||||
@@ -2463,14 +2466,6 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
local save = self.save
|
||||
self.battle.over = true
|
||||
self.battle.outcome = "caught"
|
||||
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
|
||||
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
|
||||
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
|
||||
-- send-out left in a battle that is already over. Latched here as well as
|
||||
-- from the animation's own last step so that a throw the player skipped with
|
||||
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
|
||||
-- caught mon back on the field for the "Gotcha!" line.
|
||||
self.picHidden.enemy = true
|
||||
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
|
||||
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
|
||||
-- whole effect; its catch rate is a plain ball's. It applies on the box
|
||||
@@ -2932,6 +2927,7 @@ function BattleState:useItem(itemId)
|
||||
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
|
||||
-- pushed above is drained only once the ball has finished wobbling.
|
||||
self:startBallAnim(self:ballAnimParam(itemId), itemId)
|
||||
if caught and not self.anim then self.picHidden.enemy = true end
|
||||
self.message = nil
|
||||
self.messageTimer = 0
|
||||
self.phase = "resolving"
|
||||
|
||||
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
|
||||
self.game = game
|
||||
self.save = opts.save or (game and game.save)
|
||||
local data = game and game.data or {}
|
||||
-- Held, not just read into the fields below. CRY resolves its sample
|
||||
-- through data.audio.cries and AREA resolves nests and landmark names
|
||||
-- through data.maps / data.landmarks, and every one of those reads
|
||||
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
|
||||
-- returned before it reached Sound, and the button did nothing at all.
|
||||
-- Taken by reference so a mod's merged cry or landmark is the one used.
|
||||
-- engine/pokedex/pokedex.asm:447
|
||||
self.data = data
|
||||
self.dex = opts.pokedex or data.gen2Pokedex
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
|
||||
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
|
||||
|
||||
local G = love.graphics
|
||||
local table_ = self.data and self.data.landmarks
|
||||
local byIndex = self.landmarkByIndex
|
||||
if not byIndex then
|
||||
byIndex = {}
|
||||
for _, entry in pairs((table_ and table_.landmarks) or {}) do
|
||||
if entry and entry.index then byIndex[entry.index] = entry end
|
||||
end
|
||||
self.landmarkByIndex = byIndex
|
||||
end
|
||||
|
||||
if #nests == 0 then
|
||||
-- A species with no grass, water or roamer entry in this region. The cart
|
||||
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
|
||||
return
|
||||
end
|
||||
|
||||
-- Blinking markers, the way the cart flashes its OBJs.
|
||||
-- engine/pokegear/pokegear.asm:2427
|
||||
local on = ((self.areaBlink or 0) % 32) < 20
|
||||
if cells and on then
|
||||
for _, index in ipairs(nests) do
|
||||
local mark = byIndex[index]
|
||||
local mark = Nests.landmark(self.data, index)
|
||||
if mark and mark.x and mark.y then
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
|
||||
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
|
||||
-- Name the first one in words as well as on the map: the flashing dot is
|
||||
-- unreadable at this size on a modern display, and the landmark name is what
|
||||
-- a player actually wants off this screen.
|
||||
local first = byIndex[nests[1]]
|
||||
local first = Nests.landmark(self.data, nests[1])
|
||||
if first and first.name then
|
||||
local name = tostring(first.name):gsub("\n", " ")
|
||||
self:text(name, 1, 16)
|
||||
|
||||
+16
-29
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
|
||||
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
|
||||
self.moving = false
|
||||
self.progress = 0
|
||||
self.animClock = 0
|
||||
self.stepFlip = false
|
||||
self.frozen = false -- scripts freeze NPCs while talking
|
||||
self.wanders = objDef.movement == "WALK"
|
||||
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
|
||||
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
|
||||
-- the 1px step vector once per call (engine/overworld/movement.asm), but
|
||||
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
|
||||
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
|
||||
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
|
||||
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
|
||||
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
|
||||
--
|
||||
-- self.stepFrames overrides the shared walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
-- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
|
||||
-- the follower's own step length (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
self.animClock = (self.animClock or 0) + 1
|
||||
-- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
|
||||
-- place, no translation.
|
||||
if self.marching then
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
@@ -122,18 +107,20 @@ end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local p = self.progress % stepLen
|
||||
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||
-- engine/overworld/movement.asm:301
|
||||
local p = (self.animClock or 0) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
-- phase this frame renders to -- so a render pipeline can pose an NPC
|
||||
-- without caring which kind of entity it is. An NPC never hops, so the
|
||||
-- trailing hop flag is always false.
|
||||
-- Same contract as Player:pose; an NPC never hops, so the trailing hop
|
||||
-- flag is always false.
|
||||
function NPC:pose()
|
||||
local flip = self.stepFlip
|
||||
if self.moving then
|
||||
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
|
||||
end
|
||||
return self.sprite, self.px, self.py, self.facing,
|
||||
self:walkPhase(), self.stepFlip, false
|
||||
self:walkPhase(), flip, false
|
||||
end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
|
||||
@@ -3090,9 +3090,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
|
||||
end))
|
||||
end
|
||||
if not npc then farewell() return end
|
||||
npc.frameOverride = 3
|
||||
-- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the
|
||||
-- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88)
|
||||
local yellow = GameVersion.isYellow()
|
||||
npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1
|
||||
-- bubble = false is the silent world hold, this port's DelayFrames
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
|
||||
self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function()
|
||||
npc.frameOverride = nil
|
||||
npc:facePlayer(self.player)
|
||||
farewell()
|
||||
|
||||
+59
-12
@@ -1074,11 +1074,12 @@ function World:load()
|
||||
save.pokedex = save.pokedex or { seen = {}, caught = {} }
|
||||
save.pokedex.seen[mon.species] = true
|
||||
save.pokedex.caught[mon.species] = true
|
||||
-- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted
|
||||
-- Unown lands in the form list too (nothing in Gold gives one, but the
|
||||
-- cart's check is on the species, not on where it came from).
|
||||
-- AddPartyMon's `.registerunowndex` runs on the same path, so a
|
||||
-- gifted Unown lands in the form list too (move_mon.asm:347).
|
||||
Unown.registerCatch(save, mon)
|
||||
end
|
||||
-- engine/pokemon/move_mon.asm:1632-1645
|
||||
return mon
|
||||
end,
|
||||
giveItem = function(itemIndex, qty)
|
||||
local data = self.game and self.game.data
|
||||
@@ -2402,12 +2403,43 @@ function World:rollWild()
|
||||
return { species = def.index, level = roll.level }
|
||||
end
|
||||
|
||||
-- GetMapMusic (home/map.asm:2550)
|
||||
function World.mapMusicLabel(audio, musicByte, rocketsMahogany, rocketsRadioTower)
|
||||
if type(musicByte) ~= "number" then return nil end
|
||||
local MUSIC_MAHOGANY_MART = 100 -- constants/music_constants.asm:100
|
||||
local RADIO_TOWER_MUSIC = 0x80 -- constants/music_constants.asm:109
|
||||
local order = audio and audio.musicOrder
|
||||
local songs = audio and audio.songs
|
||||
local label
|
||||
if musicByte == MUSIC_MAHOGANY_MART then
|
||||
label = rocketsMahogany and "Music_RocketHideout" or "Music_CherrygroveCity"
|
||||
elseif musicByte >= RADIO_TOWER_MUSIC then
|
||||
label = rocketsRadioTower and "Music_RocketTheme"
|
||||
or (order and order[(musicByte - RADIO_TOWER_MUSIC) + 1])
|
||||
else
|
||||
return nil
|
||||
end
|
||||
if label and label ~= "Music_Nothing" and songs and songs[label] then
|
||||
return label
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function World:mapMusicSong(mapId)
|
||||
local audio = self.game and self.game.data and self.game.data.audio
|
||||
local def = self.maps and self.maps[mapId]
|
||||
-- ENGINE_ROCKETS_IN_MAHOGANY / _RADIO_TOWER (data/events/engine_flags.asm:40,:36)
|
||||
return World.mapMusicLabel(audio, def and def.music,
|
||||
self:engineFlag(22), self:engineFlag(18))
|
||||
end
|
||||
|
||||
function World:playMapMusic()
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime and self.map then
|
||||
-- SpecialMapMusic (home/audio.asm:397)
|
||||
Music.playMap(data, self.map.id, nil,
|
||||
FieldMoves.isSurfing(self.playerState))
|
||||
FieldMoves.isSurfing(self.playerState), nil,
|
||||
self:mapMusicSong(self.map.id))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3255,7 +3287,8 @@ function World:surfStartStep(mon)
|
||||
if audio and audio.runtime and self.map then
|
||||
-- SpecialMapMusic (home/audio.asm:397)
|
||||
Music.playMap(self.game.data, self.map.id, nil,
|
||||
FieldMoves.isSurfing(self.playerState))
|
||||
FieldMoves.isSurfing(self.playerState), nil,
|
||||
self:mapMusicSong(self.map.id))
|
||||
end
|
||||
if p.scriptStep then p:scriptStep(p.facing) end
|
||||
self.fieldMove = { phase = "step" }
|
||||
@@ -3565,7 +3598,7 @@ function World:rareWildMon()
|
||||
local entry = self.encounters and self.encounters.grass
|
||||
and map and self.encounters.grass[map.id]
|
||||
if not entry or not entry.slots then return nil end
|
||||
local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY")
|
||||
local key = self.tod or "DAY"
|
||||
local slots = entry.slots[key] or entry.slots.DAY
|
||||
if not slots then return nil end
|
||||
local rare = slots[4 + math.random(3)]
|
||||
@@ -3958,7 +3991,7 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
|
||||
-- Same guard World:rockRandom uses: a headless suite has no love global.
|
||||
rng = (love and love.math and love.math.random) or math.random,
|
||||
kind = kind,
|
||||
daytime = self.daytime,
|
||||
daytime = self.tod,
|
||||
environment = map and map.def and map.def.environment,
|
||||
tables = tables,
|
||||
data = self.game and self.game.data,
|
||||
@@ -4021,7 +4054,8 @@ function World:tryWildEncounter()
|
||||
if onWater then
|
||||
rate = Encounter.waterRate(tables, map.id)
|
||||
else
|
||||
rate = Encounter.grassRate(tables, map.id, self.daytime)
|
||||
-- engine/overworld/wildmons.asm:283
|
||||
rate = Encounter.grassRate(tables, map.id, self.tod)
|
||||
end
|
||||
-- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215).
|
||||
rate = World.musicEncounterRate(rate, Music.mapSong())
|
||||
@@ -4315,6 +4349,14 @@ function World:rollFishing(rod)
|
||||
-- the flag and nothing about the map changes. Roamers.Swarm.fishing is the
|
||||
-- same store CheckSwarmFlag clears when the swarm expires.
|
||||
local swarm = Roamers.Swarm.fishing(game.save)
|
||||
-- engine/events/fish.asm:24-30
|
||||
local groupRow = self.encounters.fishGroups
|
||||
and self.encounters.fishGroups[
|
||||
Encounter.fishGroupFor(self.encounters, group, swarm)]
|
||||
if groupRow and groupRow.chance
|
||||
and not Encounter.triggers(groupRow.chance, nil) then
|
||||
return "nibble"
|
||||
end
|
||||
local roll
|
||||
if Runtime.wantsHook("encounter.fishing") then
|
||||
-- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the
|
||||
@@ -5100,7 +5142,7 @@ function World:sweetScentEncounter()
|
||||
local tables = self:wildTables()
|
||||
local onWater = FieldMoves.encounterTable(collision) == "water"
|
||||
local rate = onWater and Encounter.waterRate(tables, map.id)
|
||||
or Encounter.grassRate(tables, map.id, self.daytime)
|
||||
or Encounter.grassRate(tables, map.id, self.tod)
|
||||
if not (rate and rate > 0) then return false end
|
||||
-- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does:
|
||||
-- a beast REPLACES the map's own slot rather than adding to it.
|
||||
@@ -5374,7 +5416,8 @@ function World:runSurf(result)
|
||||
if audio and audio.runtime and self.map then
|
||||
-- SpecialMapMusic (home/audio.asm:397)
|
||||
Music.playMap(self.game.data, self.map.id, nil,
|
||||
FieldMoves.isSurfing(self.playerState))
|
||||
FieldMoves.isSurfing(self.playerState), nil,
|
||||
self:mapMusicSong(self.map.id))
|
||||
end
|
||||
if self.player and self.player.scriptStep then
|
||||
self.player:scriptStep(self.player.facing)
|
||||
@@ -5886,6 +5929,8 @@ function World:startBattle(opts, onDone)
|
||||
-- World:startCatchTutorial sets it.
|
||||
tutorial = opts.tutorial,
|
||||
onDone = function(outcome)
|
||||
-- WildBattleScript's reloadmapafterbattle (engine/overworld/events.asm:1158-1162)
|
||||
self.wildCooldown = 5
|
||||
self.battleActive = nil
|
||||
game.stack:pop()
|
||||
-- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2.
|
||||
@@ -8540,7 +8585,8 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
if audio and audio.runtime then
|
||||
if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then
|
||||
Music.playMap(self.game.data, mapId, nil,
|
||||
FieldMoves.isSurfing(self.playerState))
|
||||
FieldMoves.isSurfing(self.playerState), nil,
|
||||
self:mapMusicSong(mapId))
|
||||
end
|
||||
end
|
||||
-- Fires with the map fully built and BEFORE the map's own scene script, so a
|
||||
@@ -8928,7 +8974,8 @@ function World:movePlayer(dir)
|
||||
local audio = self.game and self.game.data and self.game.data.audio
|
||||
if audio and audio.runtime then
|
||||
Music.playMap(self.game.data, map.id, nil,
|
||||
FieldMoves.isSurfing(self.playerState))
|
||||
FieldMoves.isSurfing(self.playerState), nil,
|
||||
self:mapMusicSong(map.id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox
|
||||
local drawn
|
||||
|
||||
local function stubFont()
|
||||
drawn = {}
|
||||
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end
|
||||
Font.drawCode = function() end
|
||||
Font.drawBox = function() end
|
||||
end
|
||||
|
||||
local function unstubFont()
|
||||
Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox
|
||||
end
|
||||
|
||||
-- a mon with fewer than four moves: the remaining rows must be dashes, not
|
||||
-- simply absent (ipairs used to stop at the last known move).
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "moveSelect",
|
||||
player = { curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } },
|
||||
moveIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
local ok, err = pcall(function() screen:drawTextArea() end)
|
||||
T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")")
|
||||
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(#rows, 4, "all four move rows are drawn, even the unused ones")
|
||||
T.eq(rows[1], "TACKLE", "the one real move prints its name")
|
||||
T.eq(rows[2], "-", "an empty slot is a dash")
|
||||
T.eq(rows[3], "-", "so is the next one")
|
||||
T.eq(rows[4], "-", "and the last one")
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
-- a full four-move mon: no dashes anywhere.
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "moveSelect",
|
||||
player = { curMoves = {
|
||||
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
|
||||
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
|
||||
} },
|
||||
data = { moves = {
|
||||
TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" },
|
||||
GROWL = { name = "GROWL", pp = 40, type = "NORMAL" },
|
||||
} },
|
||||
moveIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
screen:drawTextArea()
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(#rows, 4, "still exactly four rows")
|
||||
for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do
|
||||
T.eq(rows[i], want, "row " .. i .. " keeps its own move name")
|
||||
end
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
-- the Mimic menu shares FormatMovesString on the cart, so it gets the same
|
||||
-- dash treatment.
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "mimicSelect",
|
||||
mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } },
|
||||
data = { moves = {
|
||||
TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" },
|
||||
} },
|
||||
mimicIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
local ok = pcall(function() screen:drawTextArea() end)
|
||||
T.check(ok, "mimicSelect draws without error")
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move")
|
||||
T.eq(rows[2], "GROWL", "mimic row 2 is its second")
|
||||
T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest")
|
||||
T.eq(rows[4], "-", "including the last row")
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
T.finish("battle move slot dashes bug 1343")
|
||||
@@ -0,0 +1,80 @@
|
||||
-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to
|
||||
-- the walking one -- PalletTownDaisyScript, gated on both
|
||||
-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE.
|
||||
-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local Story = assert(loadfile("data/scripts/story.lua"))()
|
||||
local Story2 = assert(loadfile("data/scripts/story2.lua"))()
|
||||
|
||||
T.check(type(Story.BLUES_HOUSE.onEnter) == "function",
|
||||
"M.BLUES_HOUSE.onEnter exists")
|
||||
T.check(type(Story2.PALLET_TOWN.onEnter) == "function",
|
||||
"M.PALLET_TOWN.onEnter exists")
|
||||
|
||||
-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch
|
||||
-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here.
|
||||
do
|
||||
local game = { save = { flags = {} } }
|
||||
Story.BLUES_HOUSE.onEnter(game, {})
|
||||
T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true,
|
||||
"onEnter sets EVENT_ENTERED_BLUES_HOUSE")
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
|
||||
"and does not itself start the swap")
|
||||
end
|
||||
|
||||
-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must
|
||||
-- write the toggle even though BLUES_HOUSE is not the live map (the fix's
|
||||
-- own precondition, verified against src/script/Commands.lua's toggleObject
|
||||
-- writing save.objectToggles before its live-NPC early return).
|
||||
do
|
||||
local game = {
|
||||
save = {
|
||||
flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true },
|
||||
},
|
||||
}
|
||||
local ow = { map = { id = "PALLET_TOWN" } }
|
||||
Story2.PALLET_TOWN.onEnter(game, ow)
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == true,
|
||||
"both prerequisites set: EVENT_DAISY_WALKING fires")
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE
|
||||
T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table")
|
||||
T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false,
|
||||
"the sitting Daisy (DAISY1) is hidden")
|
||||
T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true,
|
||||
"the walking Daisy (DAISY2) is shown")
|
||||
end
|
||||
|
||||
-- Only having the map, without ever entering the house, must not swap her:
|
||||
-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality.
|
||||
do
|
||||
local game = {
|
||||
save = { flags = { EVENT_GOT_TOWN_MAP = true } },
|
||||
}
|
||||
Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } })
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
|
||||
"without EVENT_ENTERED_BLUES_HOUSE the swap does not fire")
|
||||
end
|
||||
|
||||
-- Re-entering Pallet Town after she has already swapped must not re-run
|
||||
-- the toggle writes (EVENT_DAISY_WALKING itself is the guard).
|
||||
do
|
||||
local game = {
|
||||
save = {
|
||||
flags = {
|
||||
EVENT_GOT_TOWN_MAP = true,
|
||||
EVENT_ENTERED_BLUES_HOUSE = true,
|
||||
EVENT_DAISY_WALKING = true,
|
||||
},
|
||||
objectToggles = {},
|
||||
},
|
||||
}
|
||||
local ow = { map = { id = "PALLET_TOWN" } }
|
||||
Story2.PALLET_TOWN.onEnter(game, ow)
|
||||
T.check(next(game.save.objectToggles) == nil,
|
||||
"already-walking Daisy: onEnter writes no toggle a second time")
|
||||
end
|
||||
|
||||
T.finish("blues_house_daisy_walking_bug1338")
|
||||
@@ -0,0 +1,49 @@
|
||||
-- engine/battle/experience.asm:69
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function newSave()
|
||||
return { player = { id = 12345, name = "RED" } }
|
||||
end
|
||||
|
||||
do
|
||||
local save = newSave()
|
||||
local homegrown = {}
|
||||
BattleState.stampOT(save, homegrown)
|
||||
T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id")
|
||||
T.eq(homegrown.ot, "RED", "and the player's own name")
|
||||
end
|
||||
|
||||
-- The bug: a mon that arrived traded (traded = true) but whose OT id was
|
||||
-- never recorded (a legacy peer, a link mon with no otId in its packet) used
|
||||
-- to get save.player.id written into otId on the very first load, which then
|
||||
-- reads identically to a mon the player caught -- awardExp's OT-id compare
|
||||
-- (BattleState.lua ~4009) permanently loses the 1.5x boost.
|
||||
do
|
||||
local save = newSave()
|
||||
local tradedNoId = { traded = true }
|
||||
BattleState.stampOT(save, tradedNoId)
|
||||
T.eq(tradedNoId.otId, nil,
|
||||
"a traded mon with no OT id is left unstamped, not silently adopted")
|
||||
T.eq(tradedNoId.ot, "RED",
|
||||
"the OT NAME fill still happens (cosmetic, not the boost gate)")
|
||||
-- a second stampOT pass (a second save/load cycle) must not adopt it either
|
||||
BattleState.stampOT(save, tradedNoId)
|
||||
T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it")
|
||||
end
|
||||
|
||||
-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched
|
||||
-- either way; this is the arm the regression never broke.
|
||||
do
|
||||
local save = newSave()
|
||||
local tradedWithId = { traded = true, otId = 777 }
|
||||
BattleState.stampOT(save, tradedWithId)
|
||||
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
|
||||
end
|
||||
|
||||
T.finish("exp traded ot survives reload bug 1265")
|
||||
@@ -0,0 +1,82 @@
|
||||
-- data/moves/animations.asm:379
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local function newSelf(opts)
|
||||
opts = opts or {}
|
||||
return setmetatable({
|
||||
anim = opts.anim,
|
||||
ballThrow = opts.ballThrow,
|
||||
picHidden = { player = false, enemy = false },
|
||||
pendingAfterAnim = nil,
|
||||
afterSendOut = nil,
|
||||
}, { __index = UI })
|
||||
end
|
||||
|
||||
-- pushCaught itself must never touch picHidden: only the animation's own
|
||||
-- steps (stepAnim) are allowed to latch the enemy pic box.
|
||||
do
|
||||
local self1 = setmetatable({
|
||||
battle = {}, tutorial = true, save = nil,
|
||||
queue = {}, picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
self1:pushCaught({ species = "RATTATA" }, "POKE_BALL")
|
||||
T.eq(self1.picHidden.enemy, false,
|
||||
"pushCaught alone does not hide the enemy pic")
|
||||
T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught")
|
||||
end
|
||||
|
||||
-- stepAnim, natural end (anim:step() returns false): a caught ball throw
|
||||
-- latches, everything else does not.
|
||||
do
|
||||
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end")
|
||||
T.eq(s.anim, nil, "the finished runner is cleared")
|
||||
end
|
||||
|
||||
do
|
||||
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, false, "a break-free throw does not latch")
|
||||
end
|
||||
|
||||
do
|
||||
local otherAnim = { animId = "ANIM_HYDRO_PUMP",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, false, "an unrelated animation never latches")
|
||||
end
|
||||
|
||||
-- stepAnim, cut short with B: the property the latch exists for -- a caught
|
||||
-- mon must not reappear even if the player skips past "Gotcha!".
|
||||
do
|
||||
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return true end, keepSprites = false }
|
||||
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches")
|
||||
T.eq(s.anim, nil, "B cuts the runner short")
|
||||
end
|
||||
|
||||
do
|
||||
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return true end, keepSprites = false }
|
||||
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch")
|
||||
end
|
||||
|
||||
T.finish("gen2 ball throw pic latch bug 1232")
|
||||
@@ -0,0 +1,58 @@
|
||||
-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
||||
|
||||
local function findLoaded(runner, gfx)
|
||||
for _, entry in ipairs(runner.loaded) do
|
||||
if entry.gfx == gfx then return entry end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
runner:loadBattlerGfx(1)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.check(head and feet, "both pseudo-sheets registered")
|
||||
T.eq(head.battler, "enemy",
|
||||
"GFX_PLAYERHEAD's tiles are the ENEMY's feet row")
|
||||
T.eq(head.tiles, 7, "seven tiles, the enemy pic's width")
|
||||
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base")
|
||||
T.eq(feet.battler, "player",
|
||||
"GFX_ENEMYFEET's tiles are the PLAYER's head row")
|
||||
T.eq(feet.tiles, 6, "six tiles, the backpic's width")
|
||||
T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base")
|
||||
T.eq(head.rows, 1, "one row each")
|
||||
T.eq(feet.rows, 1, "on both sheets")
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
runner:loadBattlerGfx(2)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.eq(head.battler, "enemy", "2ROW keeps the same crossing")
|
||||
T.eq(head.tiles, 14, "two enemy rows")
|
||||
T.eq(feet.battler, "player", "on both sides")
|
||||
T.eq(feet.tiles, 12, "two player rows")
|
||||
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base")
|
||||
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
AnimRunner.COMMANDS.battlergfx_1row(runner)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
T.eq(head and head.battler, "enemy", "the script command routes the same way")
|
||||
end
|
||||
|
||||
T.finish("gen2 battler gfx row attribution bug 1231")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
GROUND = { id = "GROUND", index = 1, category = "physical" },
|
||||
FLYING = { id = "FLYING", index = 2, category = "physical" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
|
||||
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
|
||||
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local function highRoll(n) return (n or 1) - 1 end
|
||||
|
||||
local function newBattle(moveId)
|
||||
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
|
||||
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
return Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = highRoll }), player, wild
|
||||
end
|
||||
|
||||
local function moveEvent(events)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "move" then return e end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle("DIG")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "DIG")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.check(ev ~= nil, "the charge turn queues a move event")
|
||||
T.eq(ev and ev.animParam, 1,
|
||||
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "DIG")
|
||||
local ev2 = moveEvent(battle.events)
|
||||
T.check(ev2 ~= nil, "the strike turn also queues a move event")
|
||||
T.eq(ev2 and ev2.animParam, nil,
|
||||
"DIG's strike turn leaves animParam nil, the hit script arm")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle("FLY")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "FLY")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
|
||||
end
|
||||
|
||||
-- a plain hit-and-run move never sets a parameter at all
|
||||
do
|
||||
local battle, player, wild = newBattle("DIG")
|
||||
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "TACKLE")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
|
||||
end
|
||||
|
||||
T.finish("gen2 charge move anim param bug 1293")
|
||||
@@ -0,0 +1,83 @@
|
||||
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
|
||||
-- the bag about the real COIN_CASE item id, not SILVER_WING.
|
||||
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Events = require("src.world.gen2.Events")
|
||||
|
||||
local COIN_CASE = 0x36
|
||||
local SILVER_WING = 0x47
|
||||
|
||||
-- 1) the id the handler actually queries the bag with
|
||||
local seenId
|
||||
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 100 end,
|
||||
hasItem = function(id) seenId = id return true end,
|
||||
gameCornerGame = function(_, done) done() end,
|
||||
},
|
||||
})
|
||||
vm.showTextFn = function() end
|
||||
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
|
||||
coroutine.resume(vm.co)
|
||||
T.eq(seenId, COIN_CASE,
|
||||
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
|
||||
T.check(seenId ~= SILVER_WING,
|
||||
"and specifically not SILVER_WING ($47), the pre-fix value")
|
||||
|
||||
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
|
||||
-- enough to open both machines: this is what would still fail if the id
|
||||
-- above were merely logged and not actually used to gate the machine.
|
||||
local bag = { [COIN_CASE] = true }
|
||||
local slotsOpened, flipOpened
|
||||
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return bag[id] == true end,
|
||||
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
|
||||
},
|
||||
})
|
||||
vm2.showTextFn = function() end
|
||||
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
|
||||
coroutine.resume(vm2.co)
|
||||
T.eq(slotsOpened, "slots",
|
||||
"a bag with the real COIN CASE and coins opens the slot machine")
|
||||
|
||||
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return bag[id] == true end,
|
||||
gameCornerGame = function(kind, done) flipOpened = kind done() end,
|
||||
},
|
||||
})
|
||||
vm3.showTextFn = function() end
|
||||
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
|
||||
coroutine.resume(vm3.co)
|
||||
T.eq(flipOpened, "cardflip",
|
||||
"and the same bag opens card flip too")
|
||||
|
||||
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
|
||||
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
|
||||
-- is the yield the coroutine parks on, not a call, so opening never runs
|
||||
-- behind it).
|
||||
local wrongBag = { [SILVER_WING] = true }
|
||||
local opened = false
|
||||
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return wrongBag[id] == true end,
|
||||
gameCornerGame = function() opened = true end,
|
||||
},
|
||||
})
|
||||
vm4.showTextFn = function() end
|
||||
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
|
||||
local _, refusal = coroutine.resume(vm4.co)
|
||||
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
|
||||
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
|
||||
T.check(not opened, "and the machine never opens behind it")
|
||||
|
||||
T.finish("gen2_coin_case_bug1251")
|
||||
@@ -0,0 +1,166 @@
|
||||
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
|
||||
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
|
||||
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
|
||||
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
|
||||
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- a controllable roll queue; falls back to a high roll (never fails an AI
|
||||
-- check) once drained
|
||||
local rolls
|
||||
local function rng(n)
|
||||
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
|
||||
return (n or 1) - 1
|
||||
end
|
||||
|
||||
local function newBattle(pmoves, emoves)
|
||||
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
player.moves = pmoves
|
||||
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
wild.moves = emoves
|
||||
return Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng }), player, wild
|
||||
end
|
||||
|
||||
local function findText(events, sub)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function moveEvent(events)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "move" then return e end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- gap 2:
|
||||
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
|
||||
battle:useMove(wild, player, "GROWL")
|
||||
T.check(findText(battle.events, "But it failed!"),
|
||||
"enemy GROWL fails 25% of the time with the specific line")
|
||||
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
|
||||
"an AI-failed move is marked missed (feeds the 40 frame hold)")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 200 } -- AI roll passes (>=64): lands
|
||||
battle:useMove(wild, player, "GROWL")
|
||||
T.check(not findText(battle.events, "But it failed!"),
|
||||
"the same move lands when the AI roll passes")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } },
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
|
||||
battle:useMove(player, wild, "GROWL")
|
||||
T.check(not findText(battle.events, "But it failed!"),
|
||||
"the player's own GROWL is exempt from the AI roll")
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- gap 1:
|
||||
-- the reported symptom -- the "used X!" line must hold before a failure.
|
||||
do
|
||||
local input = { wasPressed = function() return false end }
|
||||
local ui = setmetatable({
|
||||
game = { input = input },
|
||||
phase = "resolving", slideFrame = 999, messageTimer = 0,
|
||||
picHidden = { player = false, enemy = false },
|
||||
queue = {
|
||||
{ kind = "move", side = "enemy", move = "GROWL",
|
||||
text = "Enemy MACHOP used GROWL!", missed = true },
|
||||
{ kind = "message", text = "But it failed!" },
|
||||
},
|
||||
updateAlarm = function() end,
|
||||
stepHpAnim = function() return false end,
|
||||
stepExpAnim = function() return false end,
|
||||
}, { __index = UI })
|
||||
|
||||
ui:advanceQueue()
|
||||
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
|
||||
T.eq(ui.messageDelay, 40,
|
||||
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
|
||||
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
|
||||
|
||||
local frames = 0
|
||||
for _ = 1, 100 do
|
||||
if ui.message == "But it failed!" then break end
|
||||
ui:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
|
||||
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
|
||||
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
|
||||
end
|
||||
|
||||
do
|
||||
local input = { wasPressed = function() return false end }
|
||||
local ui2 = setmetatable({
|
||||
game = { input = input },
|
||||
phase = "resolving", slideFrame = 999, messageTimer = 0,
|
||||
picHidden = { player = false, enemy = false },
|
||||
queue = { { kind = "move", side = "player", move = "TACKLE",
|
||||
text = "MACHOP used TACKLE!" } },
|
||||
updateAlarm = function() end,
|
||||
stepHpAnim = function() return false end,
|
||||
stepExpAnim = function() return false end,
|
||||
animForMove = function() return false end,
|
||||
}, { __index = UI })
|
||||
ui2:advanceQueue()
|
||||
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
|
||||
end
|
||||
|
||||
T.finish("gen2 enemy move fail text bug 1296")
|
||||
@@ -0,0 +1,92 @@
|
||||
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
|
||||
-- group's OWN chance byte before the rod's cumulative list even runs
|
||||
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
|
||||
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
|
||||
-- rod). A cache built before the extractor carried the byte has no
|
||||
-- `chance` field on the group row at all and must keep fishing unconditionally.
|
||||
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local DATA = {
|
||||
pokemon = {
|
||||
MAGIKARP = {
|
||||
name = "MAGIKARP", types = { "WATER", "WATER" },
|
||||
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
|
||||
specialAttack = 15, specialDefense = 20 },
|
||||
levelMoves = {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
|
||||
|
||||
local function fakeMap(waterCell)
|
||||
return {
|
||||
id = "TEST_MAP",
|
||||
def = { fishGroup = "FISHGROUP_POND" },
|
||||
cellCollision = function(_, x, y)
|
||||
return (x == waterCell[1] and y == waterCell[2])
|
||||
and COLL_WATER or COLL_FLOOR
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Rod lists that always hand back a species once a bite happens, so only the
|
||||
-- group gate (or its absence) decides the outcome below.
|
||||
local function fishGroups(chance)
|
||||
return {
|
||||
FISHGROUP_POND = {
|
||||
chance = chance,
|
||||
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
|
||||
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
|
||||
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function fakeWorld(chance)
|
||||
local game = { data = DATA, save = { party = {} } }
|
||||
local world = World.new(game)
|
||||
world.map = fakeMap({ 5, 4 })
|
||||
world.maps = { TEST_MAP = world.map.def }
|
||||
world.encounters = { fishGroups = fishGroups(chance) }
|
||||
world.player = { cellX = 5, cellY = 5, facing = "up" }
|
||||
return world
|
||||
end
|
||||
|
||||
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
|
||||
-- engine/events/fish.asm:24-30
|
||||
do
|
||||
local world = fakeWorld(0)
|
||||
for rod = 1, 3 do
|
||||
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
|
||||
eq(outcome, "nibble",
|
||||
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
|
||||
do
|
||||
local world = fakeWorld(256)
|
||||
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
|
||||
local outcome, wild = world:rollFishing(rod)
|
||||
eq(outcome, "battle", "chance 256 always bites on " .. rod)
|
||||
check(wild and wild.species == "MAGIKARP",
|
||||
"and the rod's own list still resolves a species")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
|
||||
do
|
||||
local world = fakeWorld(nil)
|
||||
local outcome = world:rollFishing("OLD_ROD")
|
||||
eq(outcome, "battle",
|
||||
"a cache with no group chance byte is not gated at all")
|
||||
end
|
||||
|
||||
T.finish("gen2 fishing bite gate bug1368")
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
|
||||
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
|
||||
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
|
||||
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
|
||||
-- roll its night table after dark (#1389, Gastly unobtainable), and a
|
||||
-- PALETTE_NITE cave must still roll its morning/day table at noon.
|
||||
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local DATA = {
|
||||
pokemon = {
|
||||
RATTATA = {
|
||||
name = "RATTATA", types = { "NORMAL", "NORMAL" },
|
||||
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
|
||||
specialAttack = 25, specialDefense = 35 },
|
||||
levelMoves = {},
|
||||
},
|
||||
GASTLY = {
|
||||
name = "GASTLY", types = { "GHOST", "POISON" },
|
||||
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
|
||||
specialAttack = 100, specialDefense = 35 },
|
||||
levelMoves = {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local function fullList(species)
|
||||
local slots = {}
|
||||
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
|
||||
return slots
|
||||
end
|
||||
|
||||
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
|
||||
-- and worthless, night is the whole reason the room exists. A second,
|
||||
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
|
||||
-- the rates are flipped so the two fixtures cannot agree by accident.
|
||||
local ENCOUNTERS = {
|
||||
grass = {
|
||||
SPROUT_TOWER_2F = {
|
||||
rates = { MORN = 0, DAY = 0, NITE = 256 },
|
||||
slots = {
|
||||
MORN = fullList("RATTATA"),
|
||||
DAY = fullList("RATTATA"),
|
||||
NITE = fullList("GASTLY"),
|
||||
},
|
||||
},
|
||||
ILEX_FOREST = {
|
||||
rates = { MORN = 256, DAY = 256, NITE = 0 },
|
||||
slots = {
|
||||
MORN = fullList("RATTATA"),
|
||||
DAY = fullList("RATTATA"),
|
||||
NITE = fullList("GASTLY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local COLL_FLOOR = 0x00
|
||||
|
||||
local function fakeWorld(mapId, tod, daytime)
|
||||
local game = { data = DATA,
|
||||
save = { party = { { species = "RATTATA", level = 5 } } } }
|
||||
local world = World.new(game)
|
||||
world.map = {
|
||||
id = mapId,
|
||||
def = { environment = "DUNGEON" },
|
||||
cellCollision = function() return COLL_FLOOR end,
|
||||
}
|
||||
world.maps = { [mapId] = world.map.def }
|
||||
world.encounters = ENCOUNTERS
|
||||
world.player = { cellX = 5, cellY = 5, facing = "down" }
|
||||
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
|
||||
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
|
||||
-- clock (src/world/gen2/World.lua:8271-8326).
|
||||
world.tod = tod
|
||||
world.daytime = daytime
|
||||
local battled
|
||||
world.startBattle = function(_, opts)
|
||||
battled = opts.wild and opts.wild.species
|
||||
return true
|
||||
end
|
||||
return world, function() return battled end
|
||||
end
|
||||
|
||||
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
|
||||
do
|
||||
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
|
||||
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
|
||||
eq(battled(), "GASTLY",
|
||||
"the night list wins because the lookup is the clock, not the pin")
|
||||
end
|
||||
|
||||
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
|
||||
do
|
||||
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
|
||||
check(not world:tryWildEncounter(),
|
||||
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
|
||||
eq(battled(), nil, "and nothing battled")
|
||||
end
|
||||
|
||||
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
|
||||
do
|
||||
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
|
||||
check(world:tryWildEncounter(),
|
||||
"a pinned-night map still rolls its day table at the clock's noon")
|
||||
eq(battled(), "RATTATA",
|
||||
"the day list wins because the lookup ignores the palette pin")
|
||||
end
|
||||
|
||||
T.finish("gen2 grass encounter tod bug1389")
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Gold #DEX AREA page drew no nest markers or landmark name because
|
||||
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
|
||||
-- the gen2Landmarks table Nests already resolves through (#1267).
|
||||
-- engine/pokegear/pokegear.asm:2427
|
||||
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
|
||||
-- one Johto landmark (index 5), one species that nests there
|
||||
local data = {
|
||||
gen2Encounters = {
|
||||
grass = {
|
||||
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
|
||||
},
|
||||
},
|
||||
gen2Maps = {
|
||||
ROUTE_30 = { landmark = 5 },
|
||||
},
|
||||
gen2Landmarks = {
|
||||
landmarks = {
|
||||
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- sanity: Nests.landmark itself resolves the index (never broken, per the
|
||||
-- verifier) so a failure below is isolated to drawArea's own lookup
|
||||
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
|
||||
"Nests.landmark resolves index 5 to the ROUTE 30 record")
|
||||
|
||||
-- capture what drawArea actually paints, without needing a real tile sheet
|
||||
-- or font: fill/blank/current/monName are stubbed on the instance, which
|
||||
-- Lua resolves before the PokedexMenu metatable's own methods.
|
||||
local function newSelf()
|
||||
local texts = {}
|
||||
local rects = {}
|
||||
local self = setmetatable({
|
||||
game = { save = {} },
|
||||
data = data,
|
||||
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
|
||||
areaRegion = "johto",
|
||||
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
|
||||
current = function() return { species = "RATTATA" } end,
|
||||
monName = function() return "RATTATA" end,
|
||||
fill = function() end,
|
||||
blank = function() end,
|
||||
text = function(_, str, tx, ty)
|
||||
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
|
||||
end,
|
||||
}, { __index = PokedexMenu })
|
||||
return self, texts, rects
|
||||
end
|
||||
|
||||
local realRect = love.graphics.rectangle
|
||||
local self, texts, rects
|
||||
do
|
||||
self, texts, rects = newSelf()
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
|
||||
end
|
||||
self:drawArea()
|
||||
love.graphics.rectangle = realRect
|
||||
end
|
||||
|
||||
local function hasRect(x, y)
|
||||
for _, r in ipairs(rects) do
|
||||
if r.x == x and r.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
|
||||
|
||||
local function hasText(str)
|
||||
for _, t in ipairs(texts) do
|
||||
if t.str == str then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
|
||||
|
||||
T.finish("gen2 pokedex area landmark bug 1267")
|
||||
@@ -0,0 +1,93 @@
|
||||
-- GetMapMusic (pokegold home/map.asm:2550), #1385
|
||||
-- luajit tests/engine/gen2_rocket_map_music_bug1385.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setLooping() end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setPitch() end
|
||||
function Source:setFilter() end
|
||||
function Source:getDuration() return 1 end
|
||||
|
||||
local made = {}
|
||||
love.audio = {
|
||||
newSource = function(file, mode)
|
||||
made[file] = setmetatable({ file = file, mode = mode }, Source)
|
||||
return made[file]
|
||||
end,
|
||||
}
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
-- constants/music_constants.asm:100,:109
|
||||
local MUSIC_MAHOGANY_MART = 100
|
||||
local RADIO_TOWER_SENTINEL = 0x80 + 61
|
||||
|
||||
local order = {}
|
||||
order[61 + 1] = "Music_GoldenrodCity"
|
||||
local audio = {
|
||||
musicOrder = order,
|
||||
songs = {
|
||||
Music_RocketHideout = { file = "rocket_hideout.wav" },
|
||||
Music_CherrygroveCity = { file = "cherrygrove.wav" },
|
||||
Music_RocketTheme = { file = "rocket_theme.wav" },
|
||||
Music_GoldenrodCity = { file = "goldenrod.wav" },
|
||||
},
|
||||
}
|
||||
|
||||
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, true, false),
|
||||
"Music_RocketHideout",
|
||||
"MAHOGANY_MART_1F with rockets in the mart plays the hideout theme")
|
||||
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, false, false),
|
||||
"Music_CherrygroveCity",
|
||||
"MAHOGANY_MART_1F after the hideout is cleared plays Cherrygrove")
|
||||
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, true),
|
||||
"Music_RocketTheme",
|
||||
"RADIO_TOWER floors during the takeover play the rocket theme")
|
||||
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, false),
|
||||
"Music_GoldenrodCity",
|
||||
"RADIO_TOWER floors otherwise fall back to the low bits of the byte")
|
||||
eq(World.mapMusicLabel(audio, 72, true, true), nil,
|
||||
"a plain song id stays with the mapSongs table")
|
||||
eq(World.mapMusicLabel(audio, nil, true, true), nil,
|
||||
"a missing music byte resolves to nothing")
|
||||
|
||||
local data = { audio = {
|
||||
songs = {
|
||||
Music_RocketHideout = { file = "rocket_hideout.wav" },
|
||||
Music_Victory = { file = "victory.wav" },
|
||||
},
|
||||
mapSongs = {},
|
||||
} }
|
||||
|
||||
local function playing()
|
||||
for file, src in pairs(made) do
|
||||
if src.playing then return file end
|
||||
end
|
||||
return "(silence)"
|
||||
end
|
||||
|
||||
Music.stop()
|
||||
Music.playMap(data, "MAHOGANY_MART_1F", false, false, nil,
|
||||
"Music_RocketHideout")
|
||||
eq(playing(), "rocket_hideout.wav",
|
||||
"the resolved song overrides the empty mapSongs table")
|
||||
|
||||
Music.play(data, "Music_Victory", nil, { reason = "battle" })
|
||||
eq(playing(), "victory.wav", "the battle result theme takes over")
|
||||
Music.restoreMap(data)
|
||||
eq(playing(), "rocket_hideout.wav",
|
||||
"restoreMap replays the resolved song, ending the victory loop")
|
||||
|
||||
T.finish("gen2_rocket_map_music_bug1385")
|
||||
@@ -0,0 +1,171 @@
|
||||
-- engine/battle/move_effects/safeguard.asm:1, engine/battle/effect_commands.asm:6325
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
|
||||
FIRE = { id = "FIRE", index = 2, category = "special" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
SAFEGUARD = { id = "SAFEGUARD", name = "SAFEGUARD", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 25, effect = "EFFECT_SAFEGUARD" },
|
||||
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
|
||||
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
|
||||
SACRED_FIRE = { id = "SACRED_FIRE", name = "SACRED FIRE", power = 100,
|
||||
type = "FIRE", accuracy = 95, pp = 5, effect = "EFFECT_SACRED_FIRE",
|
||||
effectChance = 50 },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local rolls
|
||||
local function rng(n)
|
||||
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
|
||||
return (n or 1) - 1
|
||||
end
|
||||
|
||||
local function newBattle(pmoves, emoves)
|
||||
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
player.moves = pmoves
|
||||
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
wild.moves = emoves
|
||||
return Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng }), player, wild
|
||||
end
|
||||
|
||||
local function findText(events, sub)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function moveEvent(events)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "move" then return e end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- 1388a
|
||||
-- Safeguard sets the USER's own side, not the target's.
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
|
||||
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "SAFEGUARD")
|
||||
T.eq(battle.screens.player.safeguard, 5, "Safeguard sets the CASTER's side for 5 turns")
|
||||
T.check((battle.screens.enemy.safeguard or 0) == 0,
|
||||
"and never touches the opposing side")
|
||||
T.check(findText(battle.events, "covered by a veil"), "the veil line is emitted")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
|
||||
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "SAFEGUARD")
|
||||
battle.events = {}
|
||||
rolls = { 200 } -- force the enemy's AI roll not to fail on its own
|
||||
battle:useMove(wild, player, "THUNDER_WAVE")
|
||||
T.check(findText(battle.events, "protected by SAFEGUARD"),
|
||||
"an incoming status move from the OTHER side is blocked, loudly")
|
||||
T.eq(player.status, nil, "and the paralysis never lands")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.eq(ev and ev.missed, true, "the blocked move is marked missed")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "SAFEGUARD")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "SAFEGUARD")
|
||||
T.check(findText(battle.events, "But it failed!"),
|
||||
"using it again while it is already up simply fails")
|
||||
end
|
||||
|
||||
do
|
||||
-- the player's OWN status move against a safeguarded enemy is blocked too:
|
||||
-- the effect reads whichever side is being TARGETED, not just "the enemy".
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } },
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
battle.screens.enemy.safeguard = 5
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "THUNDER_WAVE")
|
||||
T.check(findText(battle.events, "protected by SAFEGUARD"),
|
||||
"the player's status move is blocked by the enemy's own safeguard")
|
||||
T.eq(wild.status, nil, "the enemy stays unstatused under its own screen")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
battle.screens.player.safeguard = 1
|
||||
battle.events = {}
|
||||
battle:tickScreens()
|
||||
T.check(findText(battle.events, "SAFEGUARD faded"), "it fades after its five turns")
|
||||
T.eq(battle.screens.player.safeguard, nil, "and clears off the side entirely")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- 1388b
|
||||
-- Sacred Fire's burn was never implemented at all.
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- everything rolls low: hit, no
|
||||
-- crit, and the 50% secondary effect chance all pass
|
||||
battle:useMove(wild, player, "SACRED_FIRE")
|
||||
T.eq(player.status, "burn", "Sacred Fire can now burn its target")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
|
||||
battle.screens.player.safeguard = 5
|
||||
battle.events = {}
|
||||
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
|
||||
battle:useMove(wild, player, "SACRED_FIRE")
|
||||
T.eq(player.status, nil, "Safeguard blocks the burn a damaging move carries")
|
||||
T.check(not findText(battle.events, "protected by SAFEGUARD"),
|
||||
"the secondary block is silent (SafeCheckSafeguard, not CheckSafeguard)")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.check(ev and not ev.missed,
|
||||
"the hit itself still lands and is not marked missed")
|
||||
end
|
||||
|
||||
T.finish("gen2 safeguard bug 1388")
|
||||
@@ -0,0 +1,47 @@
|
||||
-- engine/battle_anims/anim_commands.asm:603 BattleAnimCmd_BGP
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
||||
local BgEffects = require("src.battle.gen2.BgEffects")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
|
||||
-- data/moves/animations.asm:4509 BattleAnim_ShadowBall
|
||||
local SHADOW_BALL = {
|
||||
{ "2gfx", "BATTLE_ANIM_GFX_EGG", "BATTLE_ANIM_GFX_SMOKE" },
|
||||
{ "bgp", 0x1b },
|
||||
{ "sound", 6 * 4 + 2, 0 },
|
||||
{ "obj", "BATTLE_ANIM_OBJ_SHADOW_BALL", 64, 92, 0x2 },
|
||||
{ "wait", 32 },
|
||||
}
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({
|
||||
data = { scripts = { SHADOW_BALL = SHADOW_BALL } },
|
||||
})
|
||||
runner:start("SHADOW_BALL")
|
||||
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL, "identity ramp before the script")
|
||||
T.check(runner:step(), "the script is still running after frame one")
|
||||
T.eq(runner.bg.bgp, 0x1b,
|
||||
"anim_bgp $1b lands in wBGP: the inverted ramp the view must apply")
|
||||
runner.bg:reset()
|
||||
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL,
|
||||
"BattleAnim_RevertPals puts the identity back")
|
||||
end
|
||||
|
||||
do
|
||||
T.eq(GbcPalette.BGP_IDENTITY, 0xe4, "dc 3, 2, 1, 0")
|
||||
local colors = { "c0", "c1", "c2", "c3" }
|
||||
local out = GbcPalette.remap(colors, 0x1b)
|
||||
T.eq(out[1], "c3", "$1b is dc 0, 1, 2, 3: colour 0 shows shade 3")
|
||||
T.eq(out[2], "c2", "colour 1 shows shade 2")
|
||||
T.eq(out[3], "c1", "colour 2 shows shade 1")
|
||||
T.eq(out[4], "c0", "colour 3 shows shade 0")
|
||||
T.check(GbcPalette.remap(colors, 0xe4) == colors,
|
||||
"the identity byte returns the palette untouched")
|
||||
end
|
||||
|
||||
T.finish("gen2 shadow ball bgp bug 1269")
|
||||
@@ -0,0 +1,90 @@
|
||||
-- #1228: `givepoke` with the 3-argument (untrained) form must run
|
||||
-- GiveANickname_YesNo, the same as a wild catch does.
|
||||
-- engine/pokemon/move_mon.asm:1632-1645, 1753-1757, 1787
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
local Events = require("src.world.gen2.Events")
|
||||
|
||||
-- Elm's `givepoke CYNDAQUIL, 5, BERRY` shape: no trainer operand, so
|
||||
-- Vm.lua's arg4 fallback reads it as 0 (untrained).
|
||||
local scripts = {
|
||||
generation = 2,
|
||||
["s:give"] = {
|
||||
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
|
||||
{ op = "end" },
|
||||
},
|
||||
}
|
||||
|
||||
local mon = { species = 155, level = 5 }
|
||||
local renamed
|
||||
local vm = Vm.new(scripts, {}, Events.new(), {
|
||||
givePoke = function() return mon end,
|
||||
yesorno = function(onChoose) onChoose(true) end,
|
||||
showText = function(_, onDone) onDone() end,
|
||||
specials = {
|
||||
monName = function(species) return species == 155 and "CYNDAQUIL" or "?" end,
|
||||
renameMon = function(m, done, opts)
|
||||
renamed = { mon = m, blank = opts and opts.blank }
|
||||
done("SPARKY")
|
||||
end,
|
||||
},
|
||||
})
|
||||
|
||||
T.check(vm:start("s:give"), "starter script starts")
|
||||
for _ = 1, 10 do vm:update() end
|
||||
T.check(not vm:running(), "script finished")
|
||||
T.eq(mon.nickname, "SPARKY",
|
||||
"givepoke with trainer=0 runs the nickname prompt and stores the answer")
|
||||
T.check(renamed ~= nil and renamed.mon == mon,
|
||||
"renameMon opened on the mon givepoke just handed over")
|
||||
T.check(renamed.blank == true,
|
||||
"the keyboard opens blank, same as InitNickname on a fresh catch")
|
||||
|
||||
-- The trade-gift form (trainer ~= 0, e.g. GiftSpearowName's 8-byte
|
||||
-- Route35GoldenrodGate.asm:30 shape) must never open the keyboard.
|
||||
local mon2 = { species = 155, level = 5 }
|
||||
local renamed2 = false
|
||||
local scripts2 = {
|
||||
generation = 2,
|
||||
["s:give2"] = {
|
||||
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 1 },
|
||||
{ op = "end" },
|
||||
},
|
||||
}
|
||||
local vm2 = Vm.new(scripts2, {}, Events.new(), {
|
||||
givePoke = function() return mon2 end,
|
||||
yesorno = function() error("a trainer-labelled gift must never prompt") end,
|
||||
showText = function(_, onDone) onDone() end,
|
||||
specials = { renameMon = function() renamed2 = true end },
|
||||
})
|
||||
T.check(vm2:start("s:give2"), "trainer-gift script starts")
|
||||
for _ = 1, 10 do vm2:update() end
|
||||
T.check(mon2.nickname == nil, "trainer arm leaves the nickname untouched")
|
||||
T.check(not renamed2, "and never opens the keyboard")
|
||||
|
||||
-- A NO answer, and an all-spaces keyboard entry, both leave the species
|
||||
-- name standing (_InitString's blank test, home/string.asm:6-30).
|
||||
local mon3 = { species = 155, level = 5 }
|
||||
local scripts3 = {
|
||||
generation = 2,
|
||||
["s:give3"] = {
|
||||
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
|
||||
{ op = "end" },
|
||||
},
|
||||
}
|
||||
local vm3 = Vm.new(scripts3, {}, Events.new(), {
|
||||
givePoke = function() return mon3 end,
|
||||
yesorno = function(onChoose) onChoose(false) end,
|
||||
showText = function(_, onDone) onDone() end,
|
||||
specials = {
|
||||
renameMon = function() error("NO must not open the keyboard") end,
|
||||
},
|
||||
})
|
||||
T.check(vm3:start("s:give3"), "NO-answer script starts")
|
||||
for _ = 1, 10 do vm3:update() end
|
||||
T.check(mon3.nickname == nil, "answering NO leaves the nickname unset")
|
||||
|
||||
T.finish("gen2_starter_nickname_bug1228")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- PKMN LEAGUE (the post-E4 Hall of Fame viewer) never had a screen backing
|
||||
-- it, so a PC row wired up to open it would have had nowhere to go (#1282).
|
||||
-- src/ui/LeaguePC.lua is the missing viewer; it resolves through the
|
||||
-- registry's builtin fallback with no id table edit needed, because every
|
||||
-- unregistered id falls through to `require("src.ui." .. id)`.
|
||||
-- engine/menus/league_pc.asm:1, constants/pokemon_data_constants.asm:65 (cap 50)
|
||||
-- luajit tests/engine/league_pc_bug1282.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Screens = require("src.ui.Screens")
|
||||
local LeaguePC = require("src.ui.LeaguePC")
|
||||
|
||||
local function team(species, level)
|
||||
return { { species = species, level = level, nickname = nil } }
|
||||
end
|
||||
|
||||
local function newGame(teamCount)
|
||||
local teams = {}
|
||||
for i = 1, teamCount do teams[i] = team("RATTATA", i) end
|
||||
local popped = false
|
||||
local game = {
|
||||
data = { pokemon = {}, text = {} },
|
||||
save = { hallOfFame = teams },
|
||||
stack = { pop = function() popped = true end },
|
||||
}
|
||||
return game, function() return popped end
|
||||
end
|
||||
|
||||
-- the registry needs no LeaguePC id entry: an unregistered id falls
|
||||
-- through resolve()'s builtin path straight to src/ui/LeaguePC.lua
|
||||
local factory = Screens.get({ data = {} }, "LeaguePC")
|
||||
check(factory == LeaguePC, "Screens.get(\"LeaguePC\") resolves to this module")
|
||||
check(type(factory.new) == "function", "the resolved factory is constructible")
|
||||
|
||||
-- 60 recorded teams: HOF_TEAM_CAPACITY (50) means the oldest 10 are gone,
|
||||
-- so the viewer opens on the OLDEST STILL-RECORDED team, index 11
|
||||
do
|
||||
local game = newGame(60)
|
||||
local pc = LeaguePC.new(game)
|
||||
eq(pc.teamIndex, 11, "60 teams over a cap of 50 starts at team 11 (60-50+1)")
|
||||
eq(pc.monIndex, 1, "starts on the first mon of that team")
|
||||
check(pc:currentMon() ~= nil, "a current mon is resolved")
|
||||
end
|
||||
|
||||
-- A steps through every remaining team (49 more presses, 11 -> 60), then
|
||||
-- one more A on the last team's only mon closes the whole viewer
|
||||
do
|
||||
local game, wasPopped = newGame(60)
|
||||
local pc = LeaguePC.new(game)
|
||||
local doneCalled = false
|
||||
pc.onDone = function() doneCalled = true end
|
||||
for _ = 1, 49 do
|
||||
game.input = { wasPressed = function(_, b) return b == "a" end }
|
||||
pc:update(0)
|
||||
end
|
||||
eq(pc.teamIndex, 60, "49 A-presses walk from team 11 to team 60")
|
||||
check(not wasPopped(), "the viewer is still open on the last team")
|
||||
game.input = { wasPressed = function(_, b) return b == "a" end }
|
||||
pc:update(0)
|
||||
check(wasPopped(), "one more A on the last team's last mon closes the viewer")
|
||||
check(doneCalled, "onDone fires on close")
|
||||
end
|
||||
|
||||
-- B always closes immediately, from any position
|
||||
do
|
||||
local game, wasPopped = newGame(3)
|
||||
local pc = LeaguePC.new(game)
|
||||
game.input = { wasPressed = function(_, b) return b == "b" end }
|
||||
pc:update(0)
|
||||
check(wasPopped(), "B closes the viewer")
|
||||
end
|
||||
|
||||
-- an empty Hall of Fame (no wins recorded yet, or the extreme edge case of
|
||||
-- a save with the row reachable but no completed run) must not crash: A on
|
||||
-- a nil current mon closes cleanly instead of indexing into nothing
|
||||
do
|
||||
local game, wasPopped = newGame(0)
|
||||
local pc = LeaguePC.new(game)
|
||||
eq(pc.teamIndex, 1, "an empty roster clamps teamIndex to 1, not 0 or negative")
|
||||
check(pc:currentMon() == nil, "there is no current mon")
|
||||
local ok = pcall(function()
|
||||
game.input = { wasPressed = function(_, b) return b == "a" end }
|
||||
pc:update(0)
|
||||
end)
|
||||
check(ok, "A on an empty Hall of Fame does not raise")
|
||||
check(wasPopped(), "...and closes the viewer instead")
|
||||
end
|
||||
|
||||
T.finish("league pc bug 1282")
|
||||
@@ -0,0 +1,86 @@
|
||||
-- The NEW NAME / preset box drew on top of a full letter grid because
|
||||
-- NamingScreen stayed isOpaque while the preset Menu was up, so the stack's
|
||||
-- visibleBase never fell through to the screen underneath (#1329).
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:1
|
||||
-- luajit tests/engine/naming_screen_opacity_bug1329.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
|
||||
local function newGame()
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local game = { data = {} }
|
||||
game.stack = stack
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game, stack
|
||||
end
|
||||
|
||||
local game, stack = newGame()
|
||||
|
||||
-- stand-in for OakSpeech: opaque, and the state a fixed background lives on
|
||||
local backgroundDraws = 0
|
||||
local background = { isOpaque = true, draw = function() backgroundDraws = backgroundDraws + 1 end }
|
||||
stack:push(background)
|
||||
|
||||
local result = { name = nil }
|
||||
local ns = NamingScreen.new(game,
|
||||
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result.name = n end })
|
||||
stack:push(ns) -- StateStack:push calls ns:enter(), which pushes the preset Menu
|
||||
|
||||
eq(ns.choosing, true, "the screen marks itself as choosing a preset")
|
||||
eq(ns.isOpaque, false, "isOpaque is shadowed false while the preset box is up")
|
||||
|
||||
local menu = stack:top()
|
||||
check(menu ~= nil and menu ~= ns, "the preset Menu is on top of the naming screen")
|
||||
eq(#menu.items, 4, "NEW NAME plus the three presets")
|
||||
eq(menu.items[1].label, "NEW NAME", "row 1 is NEW NAME")
|
||||
|
||||
eq(stack:visibleBase(), 1, "the background (index 1) is visible, not the naming screen")
|
||||
|
||||
backgroundDraws = 0
|
||||
stack:draw()
|
||||
eq(backgroundDraws, 1, "the background actually got a draw call this frame")
|
||||
|
||||
-- picking NEW NAME (row 1) restores the grid's normal opacity
|
||||
menu.index = 1
|
||||
menu.game.input.queue.a = true
|
||||
menu:update(0)
|
||||
menu.game.input.queue.a = false
|
||||
|
||||
check(stack:top() == ns, "the Menu popped itself, the naming screen is back on top")
|
||||
eq(ns.choosing, nil, "choosing cleared")
|
||||
eq(rawget(ns, "isOpaque"), nil, "the instance field is cleared, unshadowing the class default")
|
||||
eq(ns.isOpaque, true, "isOpaque now reads true again, through the class default")
|
||||
eq(stack:visibleBase(), 2, "the naming screen itself is now the opaque base")
|
||||
|
||||
-- a preset pick instead closes the whole naming flow with that name
|
||||
local game2, stack2 = newGame()
|
||||
local background2 = { isOpaque = true, draw = function() end }
|
||||
stack2:push(background2)
|
||||
local result2 = { name = nil }
|
||||
local ns2 = NamingScreen.new(game2,
|
||||
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result2.name = n end })
|
||||
stack2:push(ns2)
|
||||
local menu2 = stack2:top()
|
||||
eq(menu2.items[4].label, "JACK", "row 4 is the third preset")
|
||||
menu2.index = 4 -- "JACK"
|
||||
menu2.game.input.queue.a = true
|
||||
menu2:update(0)
|
||||
|
||||
eq(result2.name, "JACK", "selecting a preset pops the whole flow with that name")
|
||||
eq(#stack2.states, 1, "only the background remains on the stack")
|
||||
|
||||
T.finish("naming screen opacity bug 1329")
|
||||
@@ -0,0 +1,96 @@
|
||||
-- The walking-NPC animation cadence, which the port ran at half the cart's
|
||||
-- rate (#1303). UpdateSpriteInWalkingAnimation advances one animation frame
|
||||
-- every 4 fixed steps regardless of how long the whole cell takes
|
||||
-- (engine/overworld/movement.asm:301), so a 32-frame NPC cell must show the
|
||||
-- same two-pulse cadence Player:pose already shows across its own 16.
|
||||
-- luajit tests/engine/npc_walk_cadence_bug1303.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local NPC = require("src.world.NPC")
|
||||
|
||||
local DATA = {
|
||||
sprites = {
|
||||
SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true },
|
||||
},
|
||||
}
|
||||
|
||||
local function newNpc()
|
||||
return NPC.new(DATA, "TEST_MAP", {
|
||||
index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", range = "ANY_DIR",
|
||||
movement = "STAY",
|
||||
})
|
||||
end
|
||||
|
||||
-- ---- the pure cadence: two walk pulses across one 32-frame NPC step ------
|
||||
do
|
||||
local npc = newNpc()
|
||||
npc.moving = true
|
||||
local phases = {}
|
||||
for clock = 0, 31 do
|
||||
npc.animClock = clock
|
||||
phases[clock] = npc:walkPhase()
|
||||
end
|
||||
local risingEdges = 0
|
||||
for clock = 1, 31 do
|
||||
if phases[clock] == 1 and phases[clock - 1] == 0 then
|
||||
risingEdges = risingEdges + 1
|
||||
end
|
||||
end
|
||||
eq(risingEdges, 2, "a 32-frame NPC cell shows two walk pulses, not one")
|
||||
eq(phases[0], 0, "frame 0 stands")
|
||||
eq(phases[4], 1, "frame 4 opens the first walk pulse")
|
||||
eq(phases[11], 1, "frame 11 is still the first pulse")
|
||||
eq(phases[12], 0, "frame 12 closes it back to standing")
|
||||
eq(phases[20], 1, "frame 20 opens the second walk pulse")
|
||||
eq(phases[27], 1, "frame 27 is still the second pulse")
|
||||
eq(phases[28], 0, "frame 28 closes the cycle back to standing")
|
||||
end
|
||||
|
||||
-- ---- the flip half-cycle: one flip per 16-frame half, not per whole cell -
|
||||
do
|
||||
local npc = newNpc()
|
||||
npc.moving = true
|
||||
npc.animClock = 0
|
||||
local _, _, _, _, _, flip0 = npc:pose()
|
||||
npc.animClock = 15
|
||||
local _, _, _, _, _, flip15 = npc:pose()
|
||||
npc.animClock = 16
|
||||
local _, _, _, _, _, flip16 = npc:pose()
|
||||
npc.animClock = 31
|
||||
local _, _, _, _, _, flip31 = npc:pose()
|
||||
check(not flip0, "the first half of the cell is unflipped")
|
||||
check(not flip15, "still unflipped just before frame 16")
|
||||
check(flip16, "frame 16 flips, matching Player:pose's own half-cycle")
|
||||
check(flip31, "and stays flipped through the second half")
|
||||
end
|
||||
|
||||
-- ---- standing keeps the externally-written flip (Pikachu idle contract) --
|
||||
do
|
||||
local npc = newNpc()
|
||||
npc.moving = false
|
||||
npc.stepFlip = true
|
||||
local _, _, _, _, phase, flip = npc:pose()
|
||||
eq(phase, 0, "a standing NPC has no walk phase")
|
||||
check(flip, "and pose() reads stepFlip back exactly, not the moving formula")
|
||||
end
|
||||
|
||||
-- ---- the wiring: NPC:update advances animClock alongside progress -------
|
||||
do
|
||||
local npc = newNpc()
|
||||
npc.facing = "down"
|
||||
npc.moving = true
|
||||
npc.targetX, npc.targetY = npc.cellX, npc.cellY + 1
|
||||
local map = {}
|
||||
for i = 1, 16 do
|
||||
npc:update(map, {})
|
||||
eq(npc.animClock, i, "animClock ticks once per update, step " .. i)
|
||||
end
|
||||
check(npc.moving, "still mid-cell at 16 of the 32 ticks")
|
||||
end
|
||||
|
||||
T.finish("npc walk cadence bug1303")
|
||||
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
|
||||
|
||||
pushed[1].onDone()
|
||||
T.eq(#pushed, 1, "the farewell waits for the bow")
|
||||
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
|
||||
T.eq(nurse.frameOverride, 1, "image index $14: the nurse bows")
|
||||
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
|
||||
local hold = fakeSelf.emote or {}
|
||||
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- #1279: the rival must face DOWN at his own table cell before the taunt,
|
||||
-- same as SetSpriteFacingDirectionAndDelay does before DisplayTextID -- not
|
||||
-- just the player turning to face him.
|
||||
-- scripts/OaksLab.asm:347-351 (Red/Blue); pokeyellow scripts/OaksLab.asm:311-315
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local function fakeOw(rival)
|
||||
return {
|
||||
npcByIndex = function(_, i) return i == 1 and rival or nil end,
|
||||
map = {
|
||||
inBounds = function() return true end,
|
||||
isWalkableCell = function() return true end,
|
||||
},
|
||||
runner = { run = function(_, rows, opts) return rows, opts end },
|
||||
}
|
||||
end
|
||||
|
||||
-- capture the rows the runner was handed, since `run` is only a stub
|
||||
local function captureRun(ow)
|
||||
local captured
|
||||
ow.runner.run = function(_, rows, opts) captured = rows return true end
|
||||
return function() return captured end
|
||||
end
|
||||
|
||||
local function baseGame()
|
||||
return {
|
||||
save = {
|
||||
flags = {
|
||||
EVENT_GOT_STARTER = true,
|
||||
EVENT_BATTLED_RIVAL_IN_OAKS_LAB = false,
|
||||
EVENT_CHOSE_BULBASAUR = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Red/Blue
|
||||
do
|
||||
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
|
||||
local rival = { id = "rival" }
|
||||
local ow = fakeOw(rival)
|
||||
local getRows = captureRun(ow)
|
||||
local ok = M.onStep(baseGame(), ow, 4, 6)
|
||||
T.check(ok == true, "onStep claims the rival-challenge step")
|
||||
local rows = getRows()
|
||||
T.check(rows ~= nil, "the challenge rows reached the runner")
|
||||
T.same(rows[1], { "face_object", 1, "down" },
|
||||
"the FIRST row faces the rival down at his table (#1279)")
|
||||
T.eq(rows[2][1], "face_player_dir", "the player-facing row still follows it")
|
||||
T.eq(rows[2][2], "up", "and still turns the player up, unchanged")
|
||||
end
|
||||
|
||||
-- Yellow
|
||||
do
|
||||
local M = assert(loadfile("data/scripts/oaks_lab_yellow.lua"))()
|
||||
local rival = { id = "rival" }
|
||||
local ow = fakeOw(rival)
|
||||
local getRows = captureRun(ow)
|
||||
local ok = M.onStep(baseGame(), ow, 4, 6)
|
||||
T.check(ok == true, "yellow onStep claims the rival-challenge step")
|
||||
local rows = getRows()
|
||||
T.check(rows ~= nil, "the yellow challenge rows reached the runner")
|
||||
T.same(rows[1], { "face_object", 1, "down" },
|
||||
"yellow's first row faces the rival (object 1) down too (#1279)")
|
||||
end
|
||||
|
||||
T.finish("oaks_lab_rival_faces_down_bug1279")
|
||||
@@ -0,0 +1,73 @@
|
||||
-- #1391: the Pewter museum guide's lockstep walk, exercised from all four
|
||||
-- trigger cells around him, the way PewterGuys (engine/events/
|
||||
-- pewter_guys.asm:1-49) builds the preamble and PewterCitySuperNerd1Shows
|
||||
-- PlayerMuseumScript (scripts/PewterCity.asm:47-113) walks it out.
|
||||
--
|
||||
-- `museumEscort` had zero consumers and zero test coverage before this: a
|
||||
-- future edit to the RLE tables or the preamble map could silently break
|
||||
-- the walk and nothing would fail.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local M = assert(loadfile("data/scripts/flavor/pewter_city.lua"))()
|
||||
local esc = M.PEWTER_CITY.museumEscort
|
||||
T.check(type(esc) == "table", "museumEscort is exported")
|
||||
T.check(type(esc.plan) == "function", "museumEscort.plan is exported")
|
||||
T.check(type(esc.guySteps) == "table", "museumEscort.guySteps is exported")
|
||||
|
||||
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204):
|
||||
-- UP 6, LEFT 13, UP 3, LEFT 1 -- 23 steps by content, not just by count.
|
||||
local wantGuySteps = {}
|
||||
for _ = 1, 6 do wantGuySteps[#wantGuySteps + 1] = "up" end
|
||||
for _ = 1, 13 do wantGuySteps[#wantGuySteps + 1] = "left" end
|
||||
for _ = 1, 3 do wantGuySteps[#wantGuySteps + 1] = "up" end
|
||||
wantGuySteps[#wantGuySteps + 1] = "left"
|
||||
T.same(esc.guySteps, wantGuySteps,
|
||||
"guySteps is exactly UP x6, LEFT x13, UP x3, LEFT x1")
|
||||
|
||||
local function apply(x, y, dirs)
|
||||
for _, d in ipairs(dirs) do
|
||||
if d == "up" then y = y - 1
|
||||
elseif d == "down" then y = y + 1
|
||||
elseif d == "left" then x = x - 1
|
||||
elseif d == "right" then x = x + 1 end
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
|
||||
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75): the four
|
||||
-- cells adjacent to the guy's spawn (27,17), each with its own preamble.
|
||||
local triggers = { { 27, 18 }, { 27, 16 }, { 26, 17 }, { 28, 17 } }
|
||||
for _, c in ipairs(triggers) do
|
||||
local plan = esc.plan(c[1], c[2])
|
||||
T.check(plan ~= nil,
|
||||
("(%d,%d) is a real trigger cell and must produce a plan"):format(c[1], c[2]))
|
||||
T.eq(#plan.steps, 23,
|
||||
("(%d,%d): 23-step walk, same length regardless of approach side")
|
||||
:format(c[1], c[2]))
|
||||
T.eq(plan.guyHeadStart, 0,
|
||||
("(%d,%d): no NO_INPUT head padding in the museum preamble")
|
||||
:format(c[1], c[2]))
|
||||
|
||||
local px, py = apply(c[1], c[2], plan.steps)
|
||||
T.check(px == 14 and py == 8,
|
||||
("(%d,%d): player's walk ends at (14,8), by the museum door")
|
||||
:format(c[1], c[2]))
|
||||
|
||||
local guySub = {}
|
||||
for i = plan.guyHeadStart + 1, plan.guyHeadStart + #plan.steps do
|
||||
guySub[#guySub + 1] = esc.guySteps[i]
|
||||
end
|
||||
local gx, gy = apply(27, 17, guySub)
|
||||
T.check(gx == 13 and gy == 8,
|
||||
("(%d,%d): guy's walk ends at (13,8), beside the player")
|
||||
:format(c[1], c[2]))
|
||||
end
|
||||
|
||||
-- Any cell that is not one of the four adjacent trigger cells must not
|
||||
-- start the escort at all.
|
||||
T.check(esc.plan(10, 10) == nil, "a non-adjacent cell returns no plan")
|
||||
T.check(esc.plan(27, 17) == nil, "the guy's own cell is not a trigger either")
|
||||
|
||||
T.finish("pewter_museum_escort_bug1391")
|
||||
@@ -0,0 +1,95 @@
|
||||
-- The Pokedex entry page laid out its fields at the port's own invented
|
||||
-- coordinates instead of the cart's, ran the whole description together on
|
||||
-- one page with no trailing full stop, and never let A/B advance past page
|
||||
-- one (#1341).
|
||||
-- engine/menus/pokedex.asm:399, home/text.asm:245 (<PAGE>), :204 (<DEXEND>)
|
||||
-- luajit tests/engine/pokedex_entry_layout_bug1341.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- stub Font: DexEntryMenu draws through Font.draw/Font.drawCode only, and
|
||||
-- the real Font needs loaded page images this suite has no reason to touch.
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
|
||||
}
|
||||
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
|
||||
local function hasText(text, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.text == text and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function hasCode(code, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.code == code and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local game = {
|
||||
data = {
|
||||
pokemon = {
|
||||
BULBASAUR = {
|
||||
id = "BULBASAUR",
|
||||
name = "BULBASAUR",
|
||||
dex = 1,
|
||||
dexEntry = {
|
||||
kind = "SEED POKEMON",
|
||||
heightFt = 2, heightIn = 4, weight = 69,
|
||||
text = "_BulbasaurDexEntry",
|
||||
},
|
||||
},
|
||||
},
|
||||
text = {
|
||||
-- \f is the extractor's <PAGE> break; two pages, three lines each
|
||||
_BulbasaurDexEntry = "A strange seed was\nplanted on its\nback at birth\f"
|
||||
.. "The plant sprouts\nand grows with\nthis POKEMON",
|
||||
},
|
||||
constants = { dexDigits = 3 },
|
||||
},
|
||||
save = { pokedex = { owned = { BULBASAUR = true } } },
|
||||
}
|
||||
|
||||
local ns = DexEntryMenu.new(game, "BULBASAUR")
|
||||
eq(ns.pageCount, 2, "the entry has two <PAGE>-separated pages")
|
||||
eq(ns.page, 1, "starts on page 1")
|
||||
|
||||
ns:draw()
|
||||
check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)")
|
||||
check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)")
|
||||
check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)")
|
||||
check(hasText("HT 2\226\128\178" .. "04\226\128\179", 72, 48), "HT at (72,48)")
|
||||
check(hasText("WT 6.9lb", 72, 64), "WT at (72,64)")
|
||||
check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)")
|
||||
check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104")
|
||||
check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page")
|
||||
check(hasCode(0xEE, 144, 128), "the more-below arrow shows on a non-final page")
|
||||
|
||||
-- A on a non-final page turns it, it does not close the screen
|
||||
local popped, doneCalled = false, false
|
||||
game.stack = { pop = function() popped = true end }
|
||||
game.input = { wasPressed = function(_, b) return b == "a" end }
|
||||
ns.onDone = function() doneCalled = true end
|
||||
ns:update(0)
|
||||
eq(ns.page, 2, "A advances to page 2 instead of closing")
|
||||
check(not popped, "the screen did not pop on a non-final page")
|
||||
|
||||
calls = {}
|
||||
ns:draw()
|
||||
check(hasText("this POKEMON.", 8, 120), "the final page's last line gets the trailing full stop")
|
||||
check(not hasCode(0xEE, 144, 128), "no more-below arrow on the last page")
|
||||
|
||||
-- A on the final page closes the screen
|
||||
ns:update(0)
|
||||
check(popped, "A on the final page pops the screen")
|
||||
check(doneCalled, "onDone fires once the last page closes")
|
||||
|
||||
T.finish("pokedex entry layout bug 1341")
|
||||
@@ -0,0 +1,86 @@
|
||||
-- The TOWN MAP drew a blinking black square for the player instead of their
|
||||
-- walk sprite, because the marker was a placeholder rectangle instead of the
|
||||
-- OAM sprite the cart draws (#1344).
|
||||
-- engine/items/town_map.asm:347
|
||||
-- luajit tests/engine/town_map_player_sprite_bug1344.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local TownMap = require("src.ui.TownMap")
|
||||
|
||||
local function newGame()
|
||||
return {
|
||||
data = {
|
||||
field = {
|
||||
townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } },
|
||||
playerSprites = { walk = "SPRITE_RED" },
|
||||
-- no townMap.background: forces the stale-asset fallback draw path,
|
||||
-- which is the one #1344's repro screenshot came from
|
||||
},
|
||||
sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } },
|
||||
maps = {},
|
||||
},
|
||||
save = {},
|
||||
overworld = { map = { id = "PALLET_TOWN" } },
|
||||
}
|
||||
end
|
||||
|
||||
local game = newGame()
|
||||
local tm = TownMap.new(game, {})
|
||||
|
||||
eq(tm.mode, "grid", "one located entry puts the screen in grid mode")
|
||||
check(tm.bg == nil, "no background.map means the stale-asset fallback draws")
|
||||
check(tm.playerLoc ~= nil, "the player's map resolved to a location")
|
||||
check(tm.playerSheet ~= nil,
|
||||
"TownMap.new resolved the walk sheet (the fix this test guards)")
|
||||
|
||||
-- capture what :draw() actually paints, without a real screen
|
||||
local draws, rects = {}, {}
|
||||
local realDraw, realRect = love.graphics.draw, love.graphics.rectangle
|
||||
love.graphics.draw = function(img, quadOrX, x, y)
|
||||
draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y }
|
||||
end
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
|
||||
end
|
||||
|
||||
tm.blink = 0 -- (0 < 20): the player marker is in its "on" blink phase
|
||||
tm:draw()
|
||||
|
||||
love.graphics.draw = realDraw
|
||||
love.graphics.rectangle = realRect
|
||||
|
||||
local function drewSprite()
|
||||
for _, d in ipairs(draws) do
|
||||
if d.img == tm.playerSheet and d.quad == tm.playerQuad then
|
||||
return d
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local spriteDraw = drewSprite()
|
||||
check(spriteDraw ~= nil, "the player's walk sprite was drawn, not a placeholder")
|
||||
if spriteDraw then
|
||||
-- engine/items/town_map.asm:449 WriteTownMapSpriteOAM's -4,-3 carry quirk
|
||||
eq(spriteDraw.x, tm.playerLoc.x * 8 - 4, "sprite x is markerXY - 4")
|
||||
eq(spriteDraw.y, tm.playerLoc.y * 8 - 3, "sprite y is markerXY - 3")
|
||||
end
|
||||
|
||||
local function blackDotAtPlayer()
|
||||
for _, r in ipairs(rects) do
|
||||
if r.mode == "fill" and r.w == 4 and r.h == 4
|
||||
and r.x == tm.playerLoc.x * 8 + 2 and r.y == tm.playerLoc.y * 8 + 2 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
check(not blackDotAtPlayer(),
|
||||
"the old 4x4 placeholder square is not drawn once a sprite is available")
|
||||
|
||||
T.finish("town map player sprite bug 1344")
|
||||
@@ -0,0 +1,116 @@
|
||||
-- The post-battle grace period, which the port never re-armed after an
|
||||
-- UNSCRIPTED wild battle (#1229): World:tryWildEncounter's own
|
||||
-- self:startBattle({ wild = wild }) carries no onDone, so no VM resume ever
|
||||
-- ran the script's own `reloadmapafterbattle` (which is where the cart's
|
||||
-- SetUpFiveStepWildEncounterCooldown lives, engine/overworld/events.asm:1158-
|
||||
-- 1162), and the counter sat at zero for the very next step. What is
|
||||
-- asserted here is the real World:startBattle -> onDone chain, same as
|
||||
-- tests/gen2_canlose_test.lua exercises for the loss arm.
|
||||
--
|
||||
-- GOLD_CACHE=".../gold" luajit tests/gen2_wild_cooldown_bug1229_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 wild cooldown")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local cache = os.getenv("GOLD_CACHE")
|
||||
if not cache then
|
||||
local home = os.getenv("HOME") or ""
|
||||
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
||||
end
|
||||
local probe = io.open(cache .. "/data/generated/pokemon.lua", "r")
|
||||
if not probe then
|
||||
check(true, "gold cache absent (SKIP)")
|
||||
S.finish()
|
||||
return
|
||||
end
|
||||
probe:close()
|
||||
|
||||
local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end
|
||||
local pokemon = loadLua("data/generated/pokemon.lua")
|
||||
local moves = loadLua("data/generated/moves.lua")
|
||||
|
||||
-- The battle screen as a registry fake, same shape gen2_canlose_test uses:
|
||||
-- the real World:startBattle pushes it and parks its onDone for the test.
|
||||
-- Screens.get caches by id process-wide, so a suite dofile'd earlier in
|
||||
-- tests/run_tests.lua can leave a stale "Gen2BattleState" behind.
|
||||
Screens.invalidate()
|
||||
local battleDone
|
||||
local registry = {
|
||||
Gen2BattleState = { new = function(_g, opts)
|
||||
battleDone = opts.onDone
|
||||
return { screenId = "Gen2BattleState" }
|
||||
end },
|
||||
}
|
||||
|
||||
local function makeStack()
|
||||
local stack = { items = {} }
|
||||
function stack:push(inst) self.items[#self.items + 1] = inst end
|
||||
function stack:pop()
|
||||
local top = self.items[#self.items]
|
||||
self.items[#self.items] = nil
|
||||
return top
|
||||
end
|
||||
return stack
|
||||
end
|
||||
|
||||
local function makeWorld()
|
||||
battleDone = nil
|
||||
local game = {
|
||||
data = { audio = {}, screens = registry, pokemon = pokemon, moves = moves },
|
||||
-- No roamer state and no map at all: World:pushBattleTransition needs
|
||||
-- self.map to push a wipe and finds none, so pushBattle() runs straight
|
||||
-- away, exactly like a headless battle would. World:restoreMapMusic and
|
||||
-- World:battleMusicContext both already guard a nil self.map.
|
||||
save = { player = { name = "GOLD", money = 3000 }, party = {} },
|
||||
stack = makeStack(),
|
||||
}
|
||||
local mon = Mon.new(game.data, "CYNDAQUIL", 5)
|
||||
check(mon ~= nil, "the cache can build the player's starter")
|
||||
game.save.party[1] = mon
|
||||
local w = World.new(game)
|
||||
return w, game
|
||||
end
|
||||
|
||||
-- ---- the unscripted path itself: World:tryWildEncounter's own call -------
|
||||
do
|
||||
local w, game = makeWorld()
|
||||
local wild = Mon.new(game.data, "MAGIKARP", 10)
|
||||
check(wild ~= nil, "the cache can build the wild mon")
|
||||
-- A grace period already partway spent, so a re-arm is the only way to
|
||||
-- land back on 5.
|
||||
w.wildCooldown = 2
|
||||
|
||||
check(w:startBattle({ wild = wild }),
|
||||
"the unscripted wild path starts a battle")
|
||||
check(battleDone ~= nil, "the battle screen is up")
|
||||
|
||||
battleDone("win")
|
||||
eq(w.wildCooldown, 5,
|
||||
"reloadmapafterbattle's SetUpFiveStepWildEncounterCooldown re-arms " ..
|
||||
"the counter (events.asm:1158-1162), even with no script waiting")
|
||||
end
|
||||
|
||||
-- ---- the counter itself, once re-armed: four blocked steps then a roll --
|
||||
do
|
||||
local w = makeWorld()
|
||||
w.wildCooldown = 5
|
||||
for step = 1, 4 do
|
||||
check(w:wildCooldownStep(),
|
||||
"step " .. step .. " of the grace period is still blocked")
|
||||
end
|
||||
check(not w:wildCooldownStep(), "the fifth step may roll")
|
||||
end
|
||||
|
||||
-- ---- a battle NOTHING started re-arms nothing: only startBattle's onDone -
|
||||
do
|
||||
local w = makeWorld()
|
||||
w.wildCooldown = 0
|
||||
check(w.wildCooldown == 0, "a fresh world never re-arms on its own")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -183,32 +183,24 @@ do
|
||||
end
|
||||
end
|
||||
check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step")
|
||||
-- 7 is missing because that is the block the player is stood on
|
||||
check(dirsEqual(wake, { 8, 6, 5, 4, 3, 2, 1 }),
|
||||
check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }),
|
||||
"water closes in astern, stern column first")
|
||||
|
||||
-- EraseSSAnne leaves the player's own block alone ("south of the player
|
||||
-- and won't be redrawn"), so he never stands on water on the way out
|
||||
local pbx, pby = 7, 1
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
check(not (r[2] == pbx and r[3] == pby),
|
||||
"the block under the player is never rewritten")
|
||||
check(r[2] >= 1 and r[2] <= DOCK_HULL.x1,
|
||||
"the slide stays inside the dock's water, off the pier column 0")
|
||||
end
|
||||
|
||||
-- she has to end up gone: every hull block bar the player's is water by
|
||||
-- the last edit that touches it
|
||||
-- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship,
|
||||
-- the gangway block under the player included (#1211)
|
||||
local final = {}
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
final[r[2] .. "," .. r[3]] = r[4]
|
||||
end
|
||||
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
|
||||
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
|
||||
if not (bx == pbx and by == pby) then
|
||||
check(WATER[final[bx .. "," .. by]],
|
||||
("hull block (%d,%d) ends as open water"):format(bx, by))
|
||||
end
|
||||
check(WATER[final[bx .. "," .. by]],
|
||||
("hull block (%d,%d) ends as open water"):format(bx, by))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -178,8 +178,8 @@ do
|
||||
end
|
||||
check(sawSurf, "departure plays Music_Surfing")
|
||||
check(ow._queued ~= nil, "departure queues the sail-away script")
|
||||
-- #360: the surf override must NOT ride into Vermilion City, she has to
|
||||
-- sail west block by block, and the block under the player stays dry
|
||||
-- #360: the surf override must NOT ride into Vermilion City, and she
|
||||
-- sails west block by block
|
||||
local kept, horns, slid, underPlayer = false, 0, 0, false
|
||||
for _, row in ipairs(ow._queued or {}) do
|
||||
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
|
||||
@@ -194,7 +194,8 @@ do
|
||||
eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp")
|
||||
eq(horns, 2, "the horn blows before and after she sails")
|
||||
check(slid > 8, "she sails west block by block instead of vanishing")
|
||||
eq(underPlayer, false, "the block under the player is never watered over")
|
||||
-- scripts/VermilionDock.asm:182-203 (#1211)
|
||||
eq(underPlayer, true, "the gangway block under the player is erased too")
|
||||
end
|
||||
|
||||
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
|
||||
|
||||
@@ -49,7 +49,10 @@ local dex = DexEntryMenu.new(game, "PIKACHU")
|
||||
check(dex.spriteTrueColor == true,
|
||||
"Pokedex keeps a Pokemon sprite's trueColor flag")
|
||||
local dexRects = uiRects(function() dex:draw() end)
|
||||
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
|
||||
-- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8)
|
||||
local dw, dh = dex.sprite:getDimensions()
|
||||
local dx = 8 + math.floor((8 - dw / 8) / 2) * 8
|
||||
local dy = 8 + (7 - dh / 8) * 8
|
||||
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
|
||||
and dexRects[1].w == dex.sprite:getWidth()
|
||||
and dexRects[1].h == dex.sprite:getHeight(),
|
||||
|
||||
@@ -3559,6 +3559,7 @@ runSuites({
|
||||
"tests/gen2_hof_continue_test.lua",
|
||||
"tests/gen2_pokecenter_stairs_test.lua",
|
||||
"tests/gen2_canlose_test.lua",
|
||||
"tests/gen2_wild_cooldown_bug1229_test.lua",
|
||||
"tests/gen2_pc_screens_test.lua",
|
||||
"tests/gen2_badge_boosts_test.lua",
|
||||
"tests/gen2_held_items_test.lua",
|
||||
|
||||
Reference in New Issue
Block a user