more buggies

This commit is contained in:
bryanthaboi
2026-07-24 09:00:19 -04:00
parent 5312e34997
commit b40df05d2a
25 changed files with 1405 additions and 196 deletions
+85 -36
View File
@@ -616,7 +616,8 @@ M.SILPH_CO_11F = {
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): -- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole -- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch. -- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local function boulderAt(ow, x, y) local function boulderAt(ow, x, y)
@@ -682,6 +683,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end end
end, end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -694,6 +707,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches, -- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set -- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it). -- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript -> -- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript -> -- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript, -- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -702,43 +725,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script -- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room -- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record. -- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = { M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = { talk = {
TEXT_CHAMPIONSROOM_RIVAL = { TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
}, },
} }
+36 -18
View File
@@ -672,13 +672,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals -- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE -- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk -- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11). -- landing on (6,11). Vanilla WalkToLance_RLEList is
-- It marches straight across the room's water decor: -- up 12 / left 12 / down 7 / left 6 and skips collision
-- pokered's CollisionCheckOnLand skips collision -- through void tiles; our camera follows that literally
-- entirely while simulated joypad states run, and our -- and briefly shows the empty upper chamber (reads as
-- scriptMove is collision-free the same way. -- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow) local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31) ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -693,21 +701,24 @@ local function lanceLockDoor(game, ow)
end end
local function lanceWalkIn(game, ow) local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function() local i = 0
ow:scriptMove(ow.player, "left", 12, function() local function step()
ow:scriptMove(ow.player, "down", 7, function() i = i + 1
ow:scriptMove(ow.player, "left", 6, function() local seg = LANCE_WALK_IN[i]
-- the walk lands on (6,11); vanilla's per-frame coord poll if not seg then
-- then locks the door at once. scriptMove landings do not -- lands on (6,11); vanilla's per-frame coord poll then locks the
-- fire onStep, so lock here. -- door at once. scriptMove landings do not fire onStep, so lock
lanceLockDoor(game, ow) -- here.
end) lanceLockDoor(game, ow)
end) return
end) end
end) ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end end
M.LANCES_ROOM = { M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow) onEnter = function(game, ow)
lanceEntranceBlocks(game, ow) lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only -- the warp arrival lands ON the staircase trigger, and onStep only
@@ -727,7 +738,14 @@ M.LANCES_ROOM = {
end end
if not lance or ow:trainerDefeated(lance) then return false end if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player) lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end) -- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
return true return true
end end
if (x == 5 or x == 6) and y == 11 then if (x == 5 or x == 6) and y == 11 then
+124 -30
View File
@@ -52,6 +52,10 @@ local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back -- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box -- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {} local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it -- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {} local imageMeta = {}
@@ -63,7 +67,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then if not imageCache[key] then
local img, pad = nil, 0 local img, pad, padL = nil, 0, 0
if love.image and love.image.newImageData then if love.image and love.image.newImageData then
local id = Assets.imageData(path) local id = Assets.imageData(path)
if pal then if pal then
@@ -86,13 +90,25 @@ local function getImage(path, pal, trueColor)
if opaque then break end if opaque then break end
bottom = bottom - 1 bottom = bottom - 1
end end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id) img = love.graphics.newImage(id)
pad = h - 1 - bottom pad = h - 1 - bottom
padL = left
else else
img = Assets.image(path) -- headless stub: no pixel access img = Assets.image(path) -- headless stub: no pixel access
end end
imageCache[key] = img imageCache[key] = img
imagePadBottom[img] = pad imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end end
return imageCache[key] return imageCache[key]
@@ -101,7 +117,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset -- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding -- search path and re-measures its ground padding
function BattleState.invalidate() function BattleState.invalidate()
imageCache, imagePadBottom, imageMeta = {}, {}, {} imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
end end
Assets.register(BattleState.invalidate) Assets.register(BattleState.invalidate)
@@ -1667,10 +1683,19 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths. -- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim() function BattleState:cancelMoveAnim()
local row = self.moveAnimRow local row = self.moveAnimRow
if not row then return end if not row then return end
self.moveAnimRow = nil self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do for i, item in ipairs(self.queue) do
if item == row then if item == row then
table.remove(self.queue, i) table.remove(self.queue, i)
@@ -1734,14 +1759,23 @@ end
-- transient pic effects reset when a new animation row starts (each -- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives -- -- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA, so redraws keep it until -- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- the pic is reloaded (switch/Transform/ChangeMonPic) -- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
function BattleState:resetPicFx() function BattleState:resetPicFx()
if not self.picFx then return end if not self.picFx then return end
for _, pf in pairs(self.picFx) do local digFly = self.animName == "DIG" or self.animName == "FLY"
local digFlyUser = digFly and (self.animAttackerIsPlayer
and self.player or self.enemy) or nil
for battler, pf in pairs(self.picFx) do
pf.kind, pf.t = nil, nil pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0 pf.ox, pf.oy = 0, 0
pf.hidden = nil local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil
end
end end
end end
@@ -2194,6 +2228,43 @@ function BattleState:executeAction(user, target, action)
self:performMove(user, target, action, false) self:performMove(user, target, action, false)
end end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm: -- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing -- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/ -- the turn WITHOUT consuming the recharge flag. The disable/confusion/
@@ -2212,7 +2283,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!") self:sayNext(displayName(user) .. "\nwoke up!")
else else
self:sayNext(displayName(user) .. "\nis fast asleep!") self:statusOnomatopoeia(user, "sleep")
end end
return true return true
end end
@@ -2239,7 +2310,7 @@ end
-- returns true when the user's action is interrupted. -- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target) function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in -- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -2316,11 +2387,16 @@ function BattleState:performMove(user, target, moveInst, isCalled)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end end
-- PP: not for continuations, struggle, or called moves -- PP: not for continuations, struggle, called moves, or (under
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
local isContinuation = releasing local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove or moveInst == user.rageMove
if not isContinuation and not moveInst.struggle and not isCalled then local enemyUnlimited = not user.isPlayer
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
moveInst.pp = math.max(0, moveInst.pp - 1) moveInst.pp = math.max(0, moveInst.pp - 1)
end end
@@ -3540,17 +3616,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end end
elseif k == "slideUp" then elseif k == "slideUp" then
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames -- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
local scroll = 8 * math.min(7, math.floor(t / 2) + 1) -- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
local src = math.floor(scroll / scale) % h -- row so the mon emerges from underground. A cyclic wrap of a full
if src == 0 then -- pic looked like a bounce at Dig's end (#100).
love.graphics.draw(img, x + ox, y, 0, scale, scale) local step = math.min(7, math.floor((t - 1) / 2) + 1)
else local visible = math.floor(h * step / 7)
local top = love.graphics.newQuad(0, src, w, h - src, w, h) if visible > 0 then
love.graphics.draw(img, top, x + ox, y, 0, scale, scale) local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
local bottom = love.graphics.newQuad(0, 0, w, src, w, h) love.graphics.draw(img, quad, x + ox,
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale, y + (h - visible) * scale, 0, scale, scale)
0, scale, scale)
end end
elseif xscale < 1 then elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle -- AnimationSquishMonPic: columns collapse toward the middle
@@ -3762,6 +3837,20 @@ function BattleState:drawAnimLayer(colorized)
end end
end end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window -- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it -- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy) function BattleState:drawPicsLayer(slide, sx, sy)
@@ -3779,19 +3868,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY) g.intersectScissor(0, 0, 160, clipY)
clipped = true clipped = true
end end
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0). -- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out -- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic) local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx, local ex, ey = enemyPicXY(img, slide, sx, sy)
math.max(0, 48 - img:getHeight()) + sy) love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite) local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local ex = 160 - 8 - img:getWidth() - slide + sx local ex, ey = enemyPicXY(img, slide, sx, sy)
local ey = math.max(0, 48 - img:getHeight()) + sy
local gs = self:growInScale(self.enemy) local gs = self:growInScale(self.enemy)
if gs then if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
@@ -3806,14 +3894,17 @@ function BattleState:drawPicsLayer(slide, sx, sy)
end end
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96. -- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for -- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original -- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic) local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0 local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 + slide + sx, love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3821,17 +3912,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding -- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0 local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local gs = self:growInScale(self.player) local gs = self:growInScale(self.player)
if gs then if gs then
-- the player-side AnimateSendingOutMon grow (after the poof, -- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w -- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then if gs > 0 then
love.graphics.draw(img, 8 + img:getWidth() * (1 - gs) + sx, love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy, 96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs) 0, 2 * gs, 2 * gs)
end end
else else
self:drawBattlerPic(self.player, 8 + sx, self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2) 96 - (img:getHeight() - pad) * 2 + sy, 2)
end end
end end
@@ -3935,7 +4028,8 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then if self.phase == "messages" and self.current then
local shown = 0 local shown = 0
for li, codes in ipairs(self.lines) do for li, codes in ipairs(self.lines) do
local y = 104 + li * 8 -- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do for i = 1, #codes do
if shown >= self.charIndex then break end if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y) Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
+7 -2
View File
@@ -15,7 +15,8 @@
-- (super-effective) or adds 1 (not-effective when a better move exists); -- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the -- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never -- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects PP, Disable and Transform/Mimic move overrides. -- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
@@ -212,9 +213,13 @@ end
function TrainerAI.chooseMove(battler, rng, battle) function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {} local usable = {}
for i, mv in ipairs(battler.curMoves) do for i, mv in ipairs(battler.curMoves) do
if mv.pp > 0 and battler.disabledSlot ~= i then if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
table.insert(usable, mv) table.insert(usable, mv)
end end
end end
+3
View File
@@ -13,4 +13,7 @@ return {
randMax = 255, randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4 -- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true, focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
} }
+2
View File
@@ -9,4 +9,6 @@ return {
randMin = 217, randMin = 217,
randMax = 255, randMax = 255,
focusEnergyBug = false, focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
} }
+8
View File
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
end end
end end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep return FixedStep
+49 -15
View File
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
-- endFrame composites the padded canvas back with a matching offset. -- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160 Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
function Renderer:init() function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest") self.canvas:setFilter("nearest", "nearest")
@@ -36,10 +59,12 @@ function Renderer:init()
self.uprightActive = false self.uprightActive = false
end end
-- integer scale that fits the GB UI viewport in the window -- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
function Renderer:fitScale() function Renderer:fitScale()
local ww, wh = love.graphics.getDimensions() local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT))) return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end end
-- world-pass canvas size in world pixels: enough to fill the window at s'. -- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -48,9 +73,13 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns -- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive). -- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize() function Renderer:worldViewSize()
local ww, wh = love.graphics.getDimensions() local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh) local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then if Tilt.active() then
local g = Tilt.viewGrowth() local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g) vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -289,11 +318,14 @@ end
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() love.graphics.setCanvas()
local ww, wh = love.graphics.getDimensions() local ww, wh, pw, ph, dpi = displayMetrics()
local S = self:fitScale() -- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local ox = math.floor((ww - vpw) / 2) -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local oy = math.floor((wh - vph) / 2) local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -321,9 +353,9 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to -- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone -- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen pixels. -- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then if not shader then
@@ -355,11 +387,12 @@ function Renderer:endFrame(zones, worldZones)
end end
if self.worldActive then if self.worldActive then
local s = Zoom.scale(S) local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = math.floor((ww - wvw * s) / 2) local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((wh - wvh * s) / 2) local woy = math.floor((ph - wvh * sp) / 2) / dpi
-- Tilt mode projects the ground world pass through the perspective mesh -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -433,7 +466,8 @@ function Renderer:endFrame(zones, worldZones)
if present then if present then
love.graphics.setCanvas() love.graphics.setCanvas()
GBCFX.present(present, S) -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
end end
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
+3 -1
View File
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id) consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1") require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false ow.player.surfing = false
ow:warpToHealPoint() -- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
else else
showMessages(game, { "OAK: " .. game.save.player.name showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" }) .. "!\nThis isn't the\ntime to use that!" })
+204 -79
View File
@@ -1,14 +1,16 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party -- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's -- member's front sprite scrolls onto the right side of the screen
-- .ScrollPic), then its name/level shows and its cry plays -- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats -- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- shows the trainer name, play time, money and Prof. Oak's dex rating. -- "HALL OF FAME" text box before fading to the next mon. After the
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after -- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- popping itself. -- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {} local HallOfFame = {}
HallOfFame.__index = HallOfFame HallOfFame.__index = HallOfFame
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display -- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game) function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0] local mon = game.save.party[self.index or 0]
if mon then if mon then
local c = P.monPal(game.data, mon.species) local c = P.monPal(game.data, mon.species)
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
end end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per -- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The back pic (an -- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left -- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
local SCROLL_SPEED = 4 -- px/frame @ 60fps local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path) local function tryImage(path)
if not path then return nil end if not path then return nil end
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end if maxY and y > maxY then break end
Font.draw(line, x, y) Font.draw(line, x, y)
y = y + 10 y = y + 8
end end
return y return y
end end
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self return self
end end
@@ -84,16 +97,21 @@ end
function HallOfFame:nextMon() function HallOfFame:nextMon()
self.index = self.index + 1 self.index = self.index + 1
local mon = self.game.save.party[self.index] local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then if mon then
self.timer = MON_FRAMES self.phase = "mons"
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species) Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species) local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 0 local w = sprite and sprite:getWidth() or 56
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w self.scrollX = -w
else else
self.phase = "congrats" -- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
end end
end end
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
return seen, owned return seen, owned
end end
function HallOfFame:update(dt) function HallOfFame:advanceMonPhase()
local input = self.game.input if self.phase == "mons" and not self.showHofBanner then
if self.phase == "mons" then -- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
if self.scrollX and self.scrollX < self.scrollRestX then self.showHofBanner = true
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED) self.timer = HOF_HOLD
end elseif self.phase == "mons" then
self.timer = self.timer - 1 self.phase = "fade"
if input:wasPressed("a") or self.timer <= 0 then self.timer = FADE_FRAMES
self:nextMon() self.fade = 0
end elseif self.phase == "fade" then
elseif input:wasPressed("a") then self:nextMon()
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end end
end end
function HallOfFame:draw() function HallOfFame:update(dt)
love.graphics.setColor(1, 1, 1, 1) local input = self.game.input
love.graphics.rectangle("fill", 0, 0, 160, 144) local skip = input:wasPressed("a")
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index]
if mon then
local def = self.game.data.pokemon[mon.species]
love.graphics.setColor(1, 1, 1, 1)
local sprite = self:spriteFor(mon.species)
if sprite then
local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 32, 108)
Font.draw((":L%d"):format(mon.level), 112, 108)
end
else
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
-- screen is a fresh ClearScreen): trainer name, play time, money,
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
-- using the same real save-data fields as TrainerCard.lua
-- (save.player.name/playTime/money) and PokedexMenu.lua/
-- OverworldController:dexRating (save.pokedex.seen/owned).
local save = self.game.save
local text = self.game.data.text or {}
local y = 8
Font.draw(save.player.name or "RED", 8, y)
y = y + 16
local t = math.floor(save.playTime or 0)
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 8, y)
y = y + 12
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
y = y + 16
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
end
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self:advanceMonPhase()
end
elseif self.phase == "player" then
if self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
self.phase = "player_stats"
self.timer = DEX_HOLD
elseif self.phase == "player_stats" then
-- name / play time / money boxes are up; then DexSeenOwnedText
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_dex"
self.timer = DEX_HOLD
end
elseif self.phase == "player_dex" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_rating"
self.timer = DEX_HOLD
end
elseif self.phase == "player_rating" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
function HallOfFame:drawMonInfo(mon)
local def = self.game.data.pokemon[mon.species]
Font.drawBox(0, 2, 12, 11)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
-- PrintMonType at (3,9) / +2 rows for type 2
if t1 then
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
end
if dual then
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
end
end
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
if not img then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
function HallOfFame:drawPlayerStats()
local save = self.game.save
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
Font.drawBox(5, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
function HallOfFame:drawDexBox(kind)
local save = self.game.save
local text = self.game.data.text or {}
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if kind == "seen" then
local seen, owned = self:dexSeenOwned() local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6 drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y) Font.draw(ratingHeader, 1 * 8, 14 * 8)
y = y + 12
local rating = text[dexRatingKey(owned)] or "Keep it up!" local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136) drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "mons" or self.phase == "fade" then
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species))
if self.scrollX >= PIC_X then
self:drawMonInfo(mon)
if self.showHofBanner then
self:drawHofBanner()
end
end
end
if self.phase == "fade" and self.fade > 0 then
love.graphics.setColor(1, 1, 1, self.fade)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
elseif self.phase == "player" then
self:drawPic(self.playerPic)
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
elseif self.phase == "player_dex" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("seen")
elseif self.phase == "player_rating" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("rating")
end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+3 -1
View File
@@ -294,7 +294,9 @@ function PartyMenu:update(dt)
if ow and heal then if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function() self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1") require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
ow:warpToHealPoint() -- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end)) end))
end end
return return
+37 -9
View File
@@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else else
self.player = Player.new(Game.data, x, y, facing) self.player = Player.new(Game.data, x, y, facing)
end end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
self.entities = { self.player } self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
@@ -808,6 +811,15 @@ function OverworldState:update(dt)
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil self.warpEntryCell = nil
end end
-- deferred PlayMapMusic from crossConnection (issue #93)
if stepped and self.pendingSeamMusic then
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
end
end
if stepped and not scripted then if stepped and not scripted then
self:onStepComplete() self:onStepComplete()
end end
@@ -1056,7 +1068,15 @@ function OverworldState:crossConnection(dir, conn)
if not Map.defPassable(dest, ts, x, y, p.surfing) then if not Map.defPassable(dest, ts, x, y, p.surfing) then
return false return false
end end
self:setMap(conn.map, x, y, p.facing, { seamless = true }) -- keepMusic: defer PlayMapMusic until the seam step lands. Starting a
-- new chip song inside setMap used to hitch the render thread (~200ms)
-- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth
-- removed most of that hitch; discarding catch-up + deferring the song
-- still protects the visible step when neighbor rebuild or the sync
-- fallback stalls, and avoids the rare one-frame volume spike from a
-- song swap mid-step.
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
self.pendingSeamMusic = conn.map
-- place the player one cell before the seam (their old world spot, -- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step -- which the neighbor strip renders identically) and start the step
-- into the new map RIGHT NOW so there is no one-frame stall at the -- into the new map RIGHT NOW so there is no one-frame stall at the
@@ -1070,9 +1090,13 @@ function OverworldState:crossConnection(dir, conn)
p.targetX, p.targetY = x, y p.targetX, p.targetY = x, y
p.moving = true p.moving = true
p.progress = 0 p.progress = 0
-- fresh walk-cycle clock so the seam step always shows leg frames
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = Game.save.onBike p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16) or (FieldDefaults.world(Game.data, "stepFrames") or 16)
require("src.core.FixedStep"):discardCatchup()
return true return true
end end
@@ -3175,16 +3199,20 @@ end
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT). -- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed -- The heal point is usually an interior, so LAST_MAP exits are re-pointed
-- at its remembered town door rather than wherever the player left from. -- at its remembered town door rather than wherever the player left from.
function OverworldState:warpToHealPoint(onDone) --
-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim /
-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only
-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never
-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim.
function OverworldState:warpToHealPoint(onDone, opts)
local heal = self:healPoint() local heal = self:healPoint()
self.player.surfing = false self.player.surfing = false
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil Game.save.forcedBike = nil
-- rematerializing plays the teleport-in poof (EnterMapAnim in if opts and opts.arrive == "teleport" then
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then self.arriveWarp = "teleport"
-- ENTER_2 after the spin-down); blackouts take this path too end
self.arriveWarp = "teleport"
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone) self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then if heal.outdoor then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
@@ -3222,9 +3250,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- one-step justWarped guard, which only skipped the very next frame's -- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it. -- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y } self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player -- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
-- back in (player_animations.asm EnterMapAnim); ordinary door -- (player_animations.asm EnterMapAnim). Blackouts and ordinary
-- warps never take this branch -- door warps never take this branch.
if arriveWarp == "fly" then if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly") require("src.core.Sound").play(Game.data, "Fly")
elseif arriveWarp == "teleport" then elseif arriveWarp == "teleport" then
+3
View File
@@ -52,6 +52,9 @@ stub.graphics = {
push = noop, pop = noop, translate = noop, scale = noop, push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop, rotate = noop, origin = noop, setShader = noop, setScissor = noop,
getDimensions = function() return 640, 576 end, getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
} }
stub.math = { stub.math = {
+2
View File
@@ -128,6 +128,8 @@ love.graphics = {
translate = noop, scale = noop, rotate = noop, origin = noop, translate = noop, scale = noop, rotate = noop, origin = noop,
setScissor = noop, getColor = function() return 1, 1, 1, 1 end, setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
getDimensions = function() return 640, 576 end, getDimensions = function() return 640, 576 end,
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
} }
-- Fresh copies of the modules that cache a compiled shader or a page set -- Fresh copies of the modules that cache a compiled shader or a page set
+49 -1
View File
@@ -21,12 +21,17 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi
local champ = init.get("CHAMPIONS_ROOM") local champ = init.get("CHAMPIONS_ROOM")
check(champ ~= nil, "CHAMPIONS_ROOM map script registered") check(champ ~= nil, "CHAMPIONS_ROOM map script registered")
check(champ and type(champ.onEnter) == "function",
"CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists") check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
-- (2) the rival cutscene rows contain the pokered beats in order -- (2) the rival cutscene rows contain the pokered beats in order
rows = rows or {} rows = rows or {}
local preds = { local preds = {
{ "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)",
function(r) return r[1] == "play_music" and r[2] == "Music_Cities1"
and type(r[3]) == "table" and r[3].keep == true end },
{ "show_object CHAMPIONSROOM_OAK", { "show_object CHAMPIONSROOM_OAK",
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end }, function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement", { "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
@@ -82,7 +87,13 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME")
-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and -- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and
-- queues (does not directly run) the room cutscene. -- queues (does not directly run) the room cutscene.
local queued local queued
local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end } local fakeOw = {
map = { def = { signs = {} }, widthCells = 10, signAt = {} },
queueScript = function(self, script, extra)
queued = script
self.pendingScript = { script = script }
end,
}
local fakeGame = { save = { pendingHallOfFame = true } } local fakeGame = { save = { pendingHallOfFame = true } }
hof.onEnter(fakeGame, fakeOw) hof.onEnter(fakeGame, fakeOw)
check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set") check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set")
@@ -102,4 +113,41 @@ fakeGame.save.pendingHallOfFame = false
hof.onEnter(fakeGame, fakeOw) hof.onEnter(fakeGame, fakeOw)
check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed") check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed")
-- (6) Champions Room forced entrance (ChampionsRoomPlayerEntersScript):
-- from Lance (y=7), queue RivalEntrance walk then the rival battle script.
local champQueued = {}
local champOw = {
player = { cellX = 3, cellY = 7 },
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
queueScript = function(_, script, extra)
champQueued[#champQueued + 1] = { script = script, extra = extra }
end,
}
local champGame = { save = { flags = {} } }
champ.onEnter(champGame, champOw)
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
local walk = champQueued[1] and champQueued[1].script
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
check(champQueued[2] and champQueued[2].script == rows,
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
check(champQueued[2] and champQueued[2].extra
and champQueued[2].extra.npc == champOw.npcs[1],
"rival script receives the CHAMPIONSROOM_RIVAL npc")
-- already beaten this run: no forced entrance
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = true
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter idle after champion beaten this run")
-- Hall of Fame return (y=0) must not re-trigger even if the run flag is clear
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
champOw.player.cellY = 0
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter ignores Hall of Fame return landing")
S.finish() S.finish()
+26 -1
View File
@@ -221,10 +221,35 @@ do
check(fe.chooser == nil, "enemy Mimic never opens a chooser") check(fe.chooser == nil, "enemy Mimic never opens a chooser")
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK", eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
"enemy Mimic copies a random player move immediately") "enemy Mimic copies a random player move immediately")
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP") -- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's
-- full remaining PP (still 10). Player Mimic would leave 9.
eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)")
check(fe.anim and hasText(fe, "learned"), check(fe.anim and hasText(fe, "learned"),
"enemy Mimic still plays the animation and learned text") "enemy Mimic still plays the animation and learned text")
-- === #94: gen1_faithful enemies never deplete PP; player still does ===
do
local tb = freshBattle()
check(tb.ruleset.enemyUnlimitedPP,
"default ruleset grants enemy unlimited PP")
local enemyMove = { id = "TACKLE", pp = 5 }
local playerMove = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyMove)
eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.player, tb.enemy, playerMove)
eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")
-- modern_clean tracks enemy PP (Gen 2+ style)
local modern = require("src.battle.rulesets.modern_clean")
tb.ruleset = modern
local enemyModern = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyModern)
eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
end
-- link battle: the player's Mimic rolls random too (no chooser) -- link battle: the player's Mimic rolls random too (no chooser)
local tbl = freshBattle() local tbl = freshBattle()
tbl.kind = "link" tbl.kind = "link"
+31 -3
View File
@@ -115,11 +115,39 @@ do
end end
-- === STRUGGLE fallback when nothing is usable === -- === STRUGGLE fallback when nothing is usable ===
-- modern_clean / no unlimited flag: empty PP forces Struggle.
do do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } } local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data, local pick = TrainerAI.chooseMove(aiMon, rngLo, {
player = { mon = {}, curTypes = {} } }) enemyAIMods = { 1 }, data = Data,
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP") player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = false },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"STRUGGLE fallback when no PP (enemy PP tracked)")
end
-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = true },
})
eq(pick and pick.id, "TACKLE",
"gen1_faithful: enemy still picks 0-PP moves (no Struggle)")
end
-- Disable with unlimited PP: sole disabled move still yields Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = {}, data = Data,
ruleset = { enemyUnlimitedPP = true },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"gen1_faithful: Struggle only when every move is disabled")
end end
-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) === -- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) ===
+68
View File
@@ -0,0 +1,68 @@
-- Parity test (#96): blackouts must not play the Dig/Teleport EnterMapAnim.
--
-- pret HandleBlackOut fades to black and SpecialEnterMap's without setting
-- BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap skips EnterMapAnim. Dig,
-- Teleport and Escape Rope go through HandleFlyWarpOrDungeonWarp and do
-- spin. warpToHealPoint used to always set arriveWarp="teleport", so a
-- loss (notably Elite Four -> Indigo lobby) rematerialized with the
-- teleport spin instead of a plain whiteout warp.
-- Self-contained; run via `luajit tests/parity_blackout_warp.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity blackout warp")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 5) }
Game.save.party[1].hp = 0
Game.save.lastHeal = { map = "INDIGO_PLATEAU_LOBBY", x = 7, y = 6,
outdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } }
Game.stack:push(OW, "LORELEIS_ROOM", 4, 5, "up")
local ow = Game.stack:top()
Game.overworld = ow
local captured
local realStart = ow.startWarpTo
ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts)
captured = self.arriveWarp
-- skip Transition; only the arrive flag matters here
self.arriveWarp = nil
self.transitioning = false
end
-- Battle blackout (afterBattle -> warpToHealPoint): no EnterMapAnim.
captured = "sentinel"
ow:afterBattle("lose", { oppClass = "OPP_LORELEI" })
eq(captured, nil,
"Elite Four blackout does not set arriveWarp=teleport (#96)")
-- Poison / field blackout path uses the same helper with no opts.
captured = "sentinel"
ow.arriveWarp = nil
ow:warpToHealPoint()
eq(captured, nil, "plain warpToHealPoint has no teleport arrive FX")
-- Dig / Teleport / Escape Rope keep EnterMapAnim.
captured = "sentinel"
ow:warpToHealPoint(nil, { arrive = "teleport" })
eq(captured, "teleport",
"escape warps still request EnterMapAnim on arrival")
ow.startWarpTo = realStart
S.finish()
+157
View File
@@ -0,0 +1,157 @@
-- Parity: Dig / Fly semi-invulnerable pic hide (#100).
-- Dig charge SLIDE_DOWN hides the user; a cancelled Dig release (miss /
-- type immunity) must restore the pic; a successful Dig release keeps the
-- user hidden through the dirt subanim then emerges via SE_SLIDE_MON_UP
-- (not a cyclic bounce). Self-contained: `luajit tests/parity_dig_pic.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local S = require("tests.harness").suite("parity dig pic")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function makeGame(species, level, moves)
local save = SaveData.newGame()
local mon = Pokemon.new(Data, species, level)
mon.moves = moves
save.party = { mon }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
end
local function pumpToMenu(battle)
local kinds, anims = {}, {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind and not kinds[pf.kind] then
kinds[pf.kind] = true
end
if battle.animPlaying and battle.animName
and anims[#anims] ~= battle.animName then
anims[#anims + 1] = battle.animName
end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
return kinds, anims
end
local function picHidden(battle)
local pf = battle.picFx and battle.picFx[battle.player]
return pf and pf.hidden or false
end
-- Dig charge hides; Dig miss on release restores the pic (#100 vanish).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
check(battle.player.invulnerable == true, "Dig charge sets invulnerable")
check(picHidden(battle), "Dig charge leaves the user pic hidden")
battle.rng = function(a, b)
if a == 0 and b == 255 then return 255 end -- force Dig accuracy miss
return a or 0
end
battle:resolveTurn(dig)
local kinds, anims = pumpToMenu(battle)
check(not picHidden(battle),
"Dig miss on release restores the user pic (#100)")
check(not kinds.bounce, "Dig release miss never starts a bounce pic fx")
local sawDig = false
for _, name in ipairs(anims) do
if name == "DIG" then sawDig = true end
end
check(not sawDig, "Dig miss cancels the DIG release anim")
end
-- Dig hit: stay hidden through DIG start, emerge via slideUp (not bounce).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "SNORLAX", 40)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
battle:resolveTurn(dig)
local hiddenAtDigStart = nil
local kinds = {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
if battle.animPlaying and battle.animName == "DIG"
and hiddenAtDigStart == nil then
-- right after DIG row starts (resetPicFx already ran)
hiddenAtDigStart = picHidden(battle)
end
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind then kinds[pf.kind] = true end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
check(hiddenAtDigStart == true,
"DIG release keeps the digger hidden until SE_SLIDE_MON_UP")
check(kinds.slideUp == true, "Dig release uses slideUp emerge")
check(not kinds.bounce, "Dig release must not bounce (#100)")
check(not picHidden(battle), "Dig hit leaves the user pic shown")
end
S.finish()
+16
View File
@@ -113,6 +113,22 @@ eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)")
local HallOfFame = require("src.ui.HallOfFame") local HallOfFame = require("src.ui.HallOfFame")
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed")
-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone)
local hofUi = stack2:top()
eq(hofUi.phase, "mons", "induction opens on the mon showcase phase")
eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)")
-- drive past the scroll so the info box is armed
local scrollGuard = 0
while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do
scrollGuard = scrollGuard + 1
hofUi:update(1 / 60)
end
eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)")
eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold")
check(hofUi.timer == 80 or hofUi.timer < 80,
"info hold uses the pokered 80 DelayFrames window")
-- drive induction + full credits with A held (pages are unskippable; A -- drive induction + full credits with A held (pages are unskippable; A
-- only advances the induction and the final THE END wait) -- only advances the induction and the final THE END wait)
pressed.a = true pressed.a = true
+141
View File
@@ -0,0 +1,141 @@
-- Parity test: Lance room walk-in stays on the floor, and defeat dialogue
-- includes the rival-became-champion after-battle text.
--
-- Sources: scripts/LancesRoom.asm (WalkToLance / LancesRoomLanceEndBattleScript),
-- text/LancesRoom.asm (_LancesRoomLanceAfterBattleText).
-- Self-contained: run via `luajit tests/parity_lance.lua`; also dofile'd
-- by tests/run_tests.lua's aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.LANCES_ROOM) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local S = require("tests.harness").suite("parity lance")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("LANCES_ROOM")
check(hooks and hooks.walkInRoute, "LANCES_ROOM exposes walkInRoute")
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- === (1) walk-in RLE lands on the door-lock trigger (6,11) ===
do
local x, y = 24, 16
local steps = 0
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
check(d ~= nil, "walk segment direction " .. tostring(seg[1]))
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
steps = steps + 1
end
end
eq(x, 6, "walk-in ends at x=6")
eq(y, 11, "walk-in ends at y=11 (door-lock / arena entry)")
check(steps > 0, "walk-in has steps")
end
-- === (2) every cell of the route is walkable with the entrance open ===
do
local Map = require("src.world.Map")
local def = Data.maps.LANCES_ROOM
local tileset = Data.tilesets[def.tileset]
-- mutate a copy of the block list so we don't poison the registry
local blocks = {}
for i, b in ipairs(def.blocks) do blocks[i] = b end
local copy = {}
for k, v in pairs(def) do copy[k] = v end
copy.blocks = blocks
-- LanceShowOrHideEntranceBlocks with the door unlocked
blocks[6 * def.width + 2 + 1] = 0x31
blocks[6 * def.width + 3 + 1] = 0x32
local map = Map.new(copy, tileset)
local x, y = 24, 16
check(map:isWalkableCell(x, y), "start (24,16) walkable")
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
check(map:isWalkableCell(x, y),
("walk-in cell (%d,%d) is floor, not void/wall"):format(x, y))
end
end
end
-- === (3) after-battle text names the rival as the real champion ===
do
local after = Data.text._LancesRoomLanceAfterBattleText
check(after ~= nil, "_LancesRoomLanceAfterBattleText extracted")
check(after:find("ELITE", 1, true) or after:find("{RIVAL}", 1, true),
"after text mentions rival / Elite Four")
check(after:find("champion", 1, true) or after:find("CHAMPION", 1, true),
"after text has the champion reveal")
check(after:find("before you", 1, true) or after:find("before you!", 1, true)
or after:find("FOUR before", 1, true),
"after text says rival beat the Elite Four first")
local header = Data:trainerHeader("LancesRoom", 1)
check(header and header.after == "_LancesRoomLanceAfterBattleText",
"trainer header wires the after-battle label")
check(header and header.won == "_LancesRoomLanceEndBattleText",
"trainer header wires the won label")
end
-- === (4) onStep win callback pushes the after text (not only won text) ===
do
local pushed = {}
local game = {
data = Data,
save = {
flags = {},
defeatedTrainers = {},
player = { name = "RED", rival = "BLUE" },
},
stack = {
push = function(_, state)
pushed[#pushed + 1] = state
end,
},
}
local lance = {
def = { name = "LANCESROOM_LANCE", index = 1,
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
id = "LANCES_ROOM:1",
facePlayer = function() end,
}
local engaged = false
local ow = {
npcs = { lance },
player = { cellX = 6, cellY = 2 },
trainerDefeated = function(_, npc)
return game.save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function(_, npc, onDone)
engaged = npc == lance
-- simulate engageTrainer's win path: flag the trainer, then onDone
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_LANCES_ROOM_TRAINER_0 = true
if onDone then onDone() end
end,
}
local handled = hooks.onStep(game, ow, 6, 2)
check(handled == true, "Lance coord trigger engages")
check(engaged, "engageTrainer called for Lance")
check(#pushed == 1 and pushed[1].pages ~= nil, "after-battle TextBox pushed")
-- TextBox.substitute already expanded {RIVAL}/{PLAYER}; page glyphs
-- are opaque, so re-check the source label via the header + that a
-- box was queued on win only.
check(#pushed[1].pages > 0, "after-battle TextBox has pages")
-- loss path must not show after text
pushed = {}
game.save.defeatedTrainers = {}
ow.engageTrainer = function(_, npc, onDone)
-- lose: do not mark defeated
if onDone then onDone() end
end
hooks.onStep(game, ow, 6, 2)
eq(#pushed, 0, "loss does not push after-battle text")
end
print("parity_lance: ok")
+77
View File
@@ -0,0 +1,77 @@
-- Regression: map-connection seam steps must show walk frames (issue #93).
--
-- A hitch inside setMap (neighbor rebuild / map-song start) made the next
-- real-time dt huge; FixedStep catch-up then advanced many walk frames
-- before the next draw, which looked like a slide with no leg animation.
-- crossConnection now discards that catch-up, starts a fresh animClock,
-- and defers PlayMapMusic until the seam step lands.
--
-- Self-contained; run via `luajit tests/parity_seam_walk_anim.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local FixedStep = require("src.core.FixedStep")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity seam walk anim")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
local ow = Game.stack:top()
local north = ow.map:connection("north")
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
-- simulate a post-hitch catch-up budget waiting in the accumulator
FixedStep.accum = 0.24
local played = {}
local realPlayMap = Music.playMap
Music.playMap = function(data, mapId, onBike, surfing)
played[#played + 1] = mapId
return realPlayMap(data, mapId, onBike, surfing)
end
check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses")
eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1")
eq(FixedStep.accum, 0, "seam cross discards FixedStep catch-up")
eq(ow.pendingSeamMusic, "ROUTE_1", "map music deferred across the seam step")
eq(#played, 0, "PlayMapMusic not called inside setMap for the seam")
eq(ow.player.animClock, 0, "seam step starts a fresh walk-cycle clock")
check(ow.player.moving, "seam step is in progress")
local sawWalk = false
local phases = {}
for _ = 1, 20 do
ow:update(1 / 60)
phases[#phases + 1] = ow.player:walkPhase()
if ow.player:walkPhase() == 1 then sawWalk = true end
if not ow.player.moving and not ow.pendingSeamMusic then break end
end
check(sawWalk, "seam step shows at least one walk frame")
-- mid-cycle: frames 4..11 of a fresh animClock are walk
local midWalk = false
for i = 4, 11 do
if phases[i] == 1 then midWalk = true break end
end
check(midWalk, "fresh animClock puts walk frames in the middle of the seam step")
eq(ow.pendingSeamMusic, nil, "deferred music flushed after the seam step")
eq(played[1], "ROUTE_1", "PlayMapMusic runs once the seam step lands")
Music.playMap = realPlayMap
S.finish()
+125
View File
@@ -0,0 +1,125 @@
-- Parity: sleep/confusion onomatopoeia on status-check text
-- (core.asm CheckPlayerStatusConditions / CheckEnemyStatusConditions).
-- Self-contained: `luajit tests/parity_status_onomatopoeia.lua`; also
-- dofile'd by tests/run_tests.lua's parity_* aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity status onomatopoeia")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "NIDOKING", 40) }
return BattleState.newWild(Game, "DEWGONG", 30)
end
-- Capture queue order of anim/text rows inserted via *Next helpers.
local function capture(battle)
local seq = {}
battle.nextInsert = 0
battle.queue = {}
battle.animNext = function(_, name, isPlayer)
seq[#seq + 1] = { kind = "anim", name = name, isPlayer = isPlayer }
end
battle.sayNext = function(_, text)
seq[#seq + 1] = { kind = "text", text = text }
end
return seq
end
-- --- sleep: player anim-before-text, enemy text-before-anim -------------
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:statusInterrupt(b.player, b.enemy) == true, "sleep interrupts the turn")
eq(#seq, 2, "sleep queues anim + text")
eq(seq[1].kind, "anim", "player sleep: SLP_PLAYER_ANIM before text")
eq(seq[1].name, "SLP_PLAYER_ANIM", "player sleep uses SLP_PLAYER_ANIM")
eq(seq[1].isPlayer, true, "player sleep anim faces the player")
check(seq[2].text:find("is fast asleep!", 1, true),
"player sleep text follows the anim")
end
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.enemy.mon.status = "SLP"
b.enemy.sleepTurns = 3
check(b:statusInterrupt(b.enemy, b.player) == true, "enemy sleep interrupts")
eq(seq[1].kind, "text", "enemy sleep: FastAsleepText before anim")
check(seq[1].text:find("Enemy ", 1, true),
"enemy sleep text carries the Enemy prefix")
eq(seq[2].name, "SLP_ANIM", "enemy sleep uses SLP_ANIM (enemy Z coords)")
eq(seq[2].isPlayer, false, "enemy sleep anim faces the enemy")
end
do
local b = freshBattle()
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:preRechargeChecks(b.player, b.enemy) == true,
"pre-recharge sleep still loses the turn")
eq(seq[1].name, "SLP_PLAYER_ANIM",
"pre-recharge sleep plays the same onomatopoeia")
end
-- --- confusion: text then CONF_*_ANIM (both sides) ---------------------
-- Status.beforeMove: rng(0,255) < 128 -> hurt itself; else can still move.
do
local b = freshBattle()
b.rng = function() return 200 end -- no self-hit
local seq = capture(b)
b.player.confusedTurns = 3
b.player.mon.status = nil
local stopped = b:statusInterrupt(b.player, b.enemy)
check(stopped == false, "confusion can still allow a move")
eq(seq[1].kind, "text", "player confusion: IsConfusedText before anim")
check(seq[1].text:find("is confused!", 1, true), "player confusion text")
eq(seq[2].name, "CONF_PLAYER_ANIM", "player confusion uses CONF_PLAYER_ANIM")
eq(seq[2].isPlayer, true, "player confusion anim faces the player")
end
do
local b = freshBattle()
b.rng = function() return 0 end -- self-hit
local seq = capture(b)
b.computeDamage = function() return 1 end
b.applyDamage = function() end
b.onFaint = function() end
b.enemy.confusedTurns = 3
b.enemy.mon.status = nil
local stopped = b:statusInterrupt(b.enemy, b.player)
check(stopped == true, "confusion self-hit interrupts")
eq(seq[1].kind, "text", "enemy confusion text first")
check(seq[1].text:find("is confused!", 1, true), "enemy confusion text")
eq(seq[2].name, "CONF_ANIM", "enemy confusion uses CONF_ANIM")
eq(seq[2].isPlayer, false, "enemy confusion anim faces the enemy")
check(seq[3] and seq[3].text:find("hurt itself", 1, true),
"hurt-itself text follows the confusion anim")
end
-- wake stays text-only (no onomatopoeia)
do
local b = freshBattle()
b.rng = function() return 0 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 1
b:statusInterrupt(b.player, b.enemy)
eq(#seq, 1, "waking up is text-only")
check(seq[1].text:find("woke up!", 1, true), "wake text")
end
S.finish()
+63
View File
@@ -0,0 +1,63 @@
-- Parity test: Victory Road 3F hole at (23,15) dungeon-warps the player
-- to VICTORY_ROAD_2F (22,16).
--
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript feeds
-- .SwitchOrHoleCoords into IsPlayerOnDungeonWarp with destination
-- VICTORY_ROAD_2F; data/maps/special_warps.asm DungeonWarpList entry
-- (VICTORY_ROAD_2F, 2) lands at DungeonWarpData (22, 16). The same cell
-- also drops a boulder (onBoulderMoved), but the player fall was missing
-- -- CAVERN $22 is walkable, so without onStep Red stood on the hole
-- (GitHub #86).
--
-- Self-contained; run via `luajit tests/parity_victory_road_hole.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity victory road hole")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story.lua")
local vr3 = M.VICTORY_ROAD_3F
check(vr3 ~= nil and vr3.onStep ~= nil, "VICTORY_ROAD_3F has an onStep hole trigger")
local function owRecording()
local warps = {}
return {
player = { facing = "right" },
startWarpTo = function(_, mapId, x, y, facing)
warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing }
end,
_warps = warps,
}, warps
end
-- Stepping onto the hole falls through to 2F at the dungeon-warp landing.
do
local ow, warps = owRecording()
local handled = vr3.onStep({}, ow, 23, 15)
check(handled, "stepping on (23,15) is consumed")
eq(#warps, 1, "exactly one dungeon warp fires")
eq(warps[1].mapId, "VICTORY_ROAD_2F", "destination is VICTORY_ROAD_2F")
eq(warps[1].x, 22, "lands at x=22")
eq(warps[1].y, 16, "lands at y=16")
eq(warps[1].facing, "right", "facing is preserved across the fall")
end
-- Any other cell is ignored (switch at 3,5 is boulder-only).
do
local ow, warps = owRecording()
eq(vr3.onStep({}, ow, 3, 5), false, "the switch cell does not dungeon-warp")
eq(vr3.onStep({}, ow, 22, 15), false, "a neighboring floor cell is ignored")
eq(#warps, 0, "no warp fires off the hole")
end
-- The hole collision tile stays walkable (fall, do not block).
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VICTORY_ROAD_3F) then Data:load() end
local MapLoader = require("src.world.MapLoader")
local map = MapLoader.load(Data, "VICTORY_ROAD_3F")
check(map:isWalkableCell(23, 15), "CAVERN hole tile at (23,15) is walkable")
eq(map:warpPadOrHoleAt(23, 15), "hole", "collision tile is the CAVERN hole ($22)")
check(map:warpAtCell(23, 15) == nil,
"the hole is a dungeon warp, not a map warp event")
S.finish()
+86
View File
@@ -1659,6 +1659,47 @@ do
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y") eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
end end
-- ---------------------------------------------------------------- dpi fit scale (#87)
-- Android density is often non-integer; fitScale must use framebuffer
-- pixels so each GB pixel maps to a whole number of physical pixels.
do
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
local g = love.graphics
local oldDim, oldPix, oldDpi = g.getDimensions, g.getPixelDimensions, g.getDPIScale
-- desktop / dpi=1: identical to the pre-fix unit-based floor scale
g.getDimensions = function() return 1920, 1080 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1 end
eq(Renderer:fitScale(), 7, "dpi=1 1080p fitScale is floor(1080/144)=7")
-- density 1.5 on a 1920x1080 panel → LOVE units 1280x720
g.getDimensions = function() return 1280, 720 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end
eq(Renderer:fitScale(), 7,
"non-integer density still picks integer framebuffer pixels (7, not 5)")
-- old unit-only math would have returned floor(min(1280/160,720/144))=5
-- and 5*1.5=7.5 physical px/GB px (shimmer). 7 physical is crisp.
Zoom.reset()
local vw, vh = Renderer:worldViewSize()
check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)")
-- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
g.getDPIScale = nil
g.getDimensions = function() return 640, 576 end
eq(Renderer:fitScale(), 4, "no pixel API: fitScale uses unit dimensions")
g.getDimensions, g.getPixelDimensions, g.getDPIScale = oldDim, oldPix, oldDpi
Zoom.reset()
end
-- ---------------------------------------------------------------- spawn filter -- ---------------------------------------------------------------- spawn filter
do do
local OW = require("src.world.OverworldController") local OW = require("src.world.OverworldController")
@@ -2486,6 +2527,51 @@ do
love.graphics.draw = origDraw love.graphics.draw = origDraw
eq(xs[1], 8, "player/old-man back pic also rests at x=8") eq(xs[1], 8, "player/old-man back pic also rests at x=8")
end end
-- Front pics: LoadUncompressedSpriteData centers in a 7x7 buffer at
-- hlcoord 12,0. A 5x5 (40x40) Squirtle rests at (104,16), not
-- right/bottom-aligned to (112,8).
do
local front = {
getWidth = function() return 40 end,
getHeight = function() return 40 end,
}
local battle = setmetatable({
showEnemyTrainer = false,
enemyHidden = false,
enemySendingOut = false,
phase = "command",
enemy = { sprite = front, isPlayer = false },
}, BattleState)
function battle:picImage(i) return i end
function battle:growInScale() return nil end
function battle:fxHidden() return false end
function battle:drawBattlerPic(b, x, y, scale)
battle._ex, battle._ey, battle._scale = x, y, scale
end
battle:drawPicsLayer(0, 0, 0)
eq(battle._ex, 104, "enemy 5x5 front rests at hlcoord 12,0 + hPad 1 (x=104)")
eq(battle._ey, 16, "enemy 5x5 front bottom-aligned in 7x7 (y=16)")
eq(battle._scale, 1, "enemy front draws at 1x")
end
-- Battle message lines skip a tile row (14 then 16), matching the menu.
do
local Font = require("src.render.Font")
local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox
Font.drawBox = function() end
Font.drawCode = function(_, _, y) ys[#ys + 1] = y end
local battle = setmetatable({
phase = "messages",
current = true,
charIndex = 999,
lines = { { 0x80 }, { 0x81 } },
}, BattleState)
battle:drawTextArea()
Font.drawCode, Font.drawBox = origCode, origBox
eq(ys[1], 112, "battle text line 1 at row 14 (y=112)")
eq(ys[2], 128, "battle text line 2 at row 16 (y=128)")
end
end end
-- ================= BUGS.md batch: ledge-shadow ================= -- ================= BUGS.md batch: ledge-shadow =================