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