diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index e542c48b..5f8d7f54 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = { M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } +-- ------------------------------------------------------------------- +-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm +-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once +-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off; +-- while on the pad the map script also sets BIT_NO_BATTLES (we return +-- true from onStep so wild encounters are skipped the same way). +-- ------------------------------------------------------------------- + +local TOWER_5F_PURIFIED = { + [10 * 256 + 8] = true, [11 * 256 + 8] = true, + [10 * 256 + 9] = true, [11 * 256 + 9] = true, +} + +-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite +-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed). +local TOWER_5F_HEAL = { + { "heal_party" }, + { "fade", "out", "white" }, + { "wait", 3 }, + { "wait", 3 }, + { "fade", "in", "white" }, + { "show_text", "_PokemonTower5FPurifiedZoneText" }, +} + +M.POKEMON_TOWER_5F = { + onStep = function(game, ow, x, y) + if not TOWER_5F_PURIFIED[x * 256 + y] then + game.save.flags.EVENT_IN_PURIFIED_ZONE = nil + return false + end + if game.save.flags.EVENT_IN_PURIFIED_ZONE then + return true + end + if ow.runner and ow.runner:isRunning() then return false end + game.save.flags.EVENT_IN_PURIFIED_ZONE = true + if ow.runner then + ow.runner:run(TOWER_5F_HEAL) + elseif ow.queueScript then + ow:queueScript(TOWER_5F_HEAL) + end + return true + end, +} + -- ------------------------------------------------------------------- -- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at -- (10,16) until defeated. diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 78d55837..09fe128b 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record) end battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) - -- the hit blink + damage sound ride the queue behind the animation: - -- on the move's anim row when one was announced, else on a bare hit - -- row (thrash/rage continuations), placed BEFORE the drain rows the - -- hits loop inserts so the blink precedes the bar drain - local hitRow = battle.moveAnimRow - if not hitRow then - battle.nextInsert = (battle.nextInsert or 0) + 1 - hitRow = { hitRow = true } - table.insert(battle.queue, battle.nextInsert, hitRow) - end + -- the hit blink + damage sound ride each animation row, placed BEFORE + -- that hit's drain so the blink precedes the bar. Multi-hit moves + -- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType + -- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the + -- announcement-time moveAnimRow, later hits queue fresh anim rows. + -- Thrash/rage continuations have no announcement anim -- a bare + -- hitRow carries the blink instead. + local hitSfx = info.typeMult > 10 and "Super_Effective" + or info.typeMult < 10 and "Not_Very_Effective" or "Damage" + local hitFx = { sfx = hitSfx, + blink = battle:animationsOn() and target or nil } local totalDealt = 0 local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end + local hitRow + if h == 1 then + hitRow = battle.moveAnimRow + if not hitRow then + battle.nextInsert = (battle.nextInsert or 0) + 1 + hitRow = { hitRow = true } + table.insert(battle.queue, battle.nextInsert, hitRow) + end + else + battle.nextInsert = (battle.nextInsert or 0) + 1 + hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer } + table.insert(battle.queue, battle.nextInsert, hitRow) + end local hadSub = target.substituteHP ~= nil local dealt = battle:applyDamage(target, dmg) totalDealt = totalDealt + dealt landed = h + if dealt > 0 then hitRow.hit = hitFx end + -- PrintCriticalOHKOText + DisplayEffectiveness run inside the + -- multi-hit loop (core.asm .moveDidNotMiss before the jump back + -- to GetPlayerAnimationType), so crit/effectiveness reprint on + -- every strike -- damage was only rolled once + if info.crit then battle:sayNext("Critical hit!") end + if info.ohko then battle:sayNext("One-hit KO!") end + if info.typeMult > 10 then + battle:sayNext("It's super\neffective!") + elseif info.typeMult < 10 then + battle:sayNext("It's not very\neffective...") + end if Runtime.wants("battle.damage_dealt") then Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, @@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end hits = landed > 0 and landed or hits - if totalDealt > 0 then - -- the original's per-hit sound: normal / super / not-very-effective - local hitSfx = info.typeMult > 10 and "Super_Effective" - or info.typeMult < 10 and "Not_Very_Effective" or "Damage" - hitRow.hit = { sfx = hitSfx, - blink = battle:animationsOn() and target or nil } - end - -- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right - -- after the damage lands, BEFORE DisplayEffectiveness (core.asm - -- .moveDidNotMiss); the multi-hit count follows the last hit - if info.crit then battle:sayNext("Critical hit!") end - if info.ohko then battle:sayNext("One-hit KO!") end - if info.typeMult > 10 then - battle:sayNext("It's super\neffective!") - elseif info.typeMult < 10 then - battle:sayNext("It's not very\neffective...") - end if hits > 1 then -- player: _MultiHitText; enemy: _HitXTimesText (always plural) if user.isPlayer then diff --git a/src/core/Game.lua b/src/core/Game.lua index a7bbb14b..5175c42f 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -322,7 +322,9 @@ function Game:keypressed(key) return elseif key == "5" then -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on + -- desktop. Mobile refuses the present shader (issue #136). local GBCFX = require("src.render.GBCFX") + if not GBCFX.isSupported() then return end self.save.options.gbcfx = GBCFX.cycle() self:writeOptions() return @@ -443,12 +445,15 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) - require("src.render.GBCFX").applyOptions(opts) + -- returns true when a persisted GBC FX level was cleared on mobile + local gbcCleared = require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) -- normalizes a nil/garbage cap to the 60 default, so old saves with no -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) Input:applyBindings(opts.bindings) + -- heal soft-bricked APK installs that already saved gbcfx > 0 (#136) + if gbcCleared then self:writeOptions() end end function Game:restoreSave(loaded, recovered) diff --git a/src/core/Input.lua b/src/core/Input.lua index 8155e3d6..d0678f62 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = { z = "a", ["return"] = "a", space = "a", x = "b", backspace = "b", ["kpenter"] = "start", escape = "start", + -- Select: fight-menu move reorder + bag item reorder. Tab is the + -- discoverable default (shown in CONTROLS); both shifts stay as + -- aliases so Right-Shift muscle memory from older builds still works. + tab = "select", rshift = "select", + lshift = "select", } -- keys that map to "start" but also to "a" would conflict; keep Enter = a, diff --git a/src/core/TouchInput.lua b/src/core/TouchInput.lua index 984c79b6..2245a7f4 100644 --- a/src/core/TouchInput.lua +++ b/src/core/TouchInput.lua @@ -48,7 +48,7 @@ local KEY = { a = "z", b = "x", start = "escape", - select = "rshift", + select = "tab", } local function nowMs() diff --git a/src/render/GBCFX.lua b/src/render/GBCFX.lua index 05f49582..0a05db7f 100644 --- a/src/render/GBCFX.lua +++ b/src/render/GBCFX.lua @@ -22,6 +22,15 @@ GBCFX.level = 0 local shader -- false = unavailable (headless / no shader support) +-- Mobile GPUs often compile this pass but present a black frame, and the +-- level persists in options.lua -- soft-bricking the APK until a manual +-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect. +function GBCFX.isSupported() + if not love or not love.system or not love.system.getOS then return true end + local osName = love.system.getOS() + return osName ~= "Android" and osName ~= "iOS" +end + -- GLSL 1.20-compatible (no array initializers; wavelength terms and the -- shadow blur are unrolled by hand). local SHADER_SRC = [[ @@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc) GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check function GBCFX.shader() + if not GBCFX.isSupported() then return nil end if shader == nil then local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) shader = ok and sh or false @@ -222,6 +232,10 @@ function GBCFX.shader() end function GBCFX.setLevel(level) + if not GBCFX.isSupported() then + GBCFX.level = 0 + return + end level = math.floor(tonumber(level) or 0) if level < 0 then level = 0 end if level > 4 then level = 4 end @@ -230,12 +244,26 @@ end -- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. function GBCFX.cycle() + if not GBCFX.isSupported() then + GBCFX.level = 0 + return 0 + end GBCFX.setLevel((GBCFX.level + 1) % 5) return GBCFX.level end +-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a +-- persisted non-zero value so boot recovers from a soft-brick. Returns +-- true when opts was sanitized (caller should persist options.lua). function GBCFX.applyOptions(opts) + if not GBCFX.isSupported() then + local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0 + if opts then opts.gbcfx = 0 end + GBCFX.level = 0 + return had and true or false + end GBCFX.setLevel(opts and opts.gbcfx or 0) + return false end function GBCFX.levelLabel(level) @@ -243,7 +271,7 @@ function GBCFX.levelLabel(level) end function GBCFX.active() - return GBCFX.level > 0 and GBCFX.shader() ~= nil + return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil end -- Draw `canvas` fullscreen through the GBC FX shader into the current diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index f1100fde..d2fa39d0 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -353,18 +353,21 @@ local TILESET_GROUP_EXCEPTIONS = { CEMETERY = { tiles = { [0x22] = true }, group = 0 }, } --- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top +-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top -- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the -- tabletop and the checkerboard floor -- both raw tile $37 -- can take -- different palettes; the vanilla-derived blockset shares the one tile -- id, so the RED++ atlas path re-creates the duplicate: the alias slot -- is baked as a copy of `tile` in `group`'s colors, and the listed -- 0-based block cells draw the alias instead of the shared tile. +-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84). +local LOBBY_TABLE_TOP_ALIAS = { + { block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, + tile = 0x37, alias = 0x5a, group = 5 }, +} PaletteFX.TILE_ALIASES = { - CELADON_MART_ROOF = { - { block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, - tile = 0x37, alias = 0x5a, group = 5 }, - }, + CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS, + CELADON_DINER = LOBBY_TABLE_TOP_ALIAS, } local ROOF_GROUP = 6 local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 8cf5c115..1e01b1fd 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -55,8 +55,29 @@ local FLOWER_IMAGES = { local SPINNER_STRIP = "assets/generated/tilesets/spinners.png" local animFrame = 0 -function TileRenderer.tick() - animFrame = animFrame + 1 +local animAccum = 0 +local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP) + +-- Advance water/flower/spinner tile animation. Called from the overworld +-- draw path so it keeps running under dialogs (overworld update does not), +-- but consumes wall-clock dt into 60Hz steps so high/low display refresh +-- no longer speeds up or slows the cycle (issue #4). +-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still +-- advances exactly one frame per call. +function TileRenderer.tick(dt) + if dt == nil and love and love.timer and love.timer.getDelta then + dt = love.timer.getDelta() + end + if dt == nil then + animFrame = animFrame + 1 + return + end + -- Cap catch-up so a long stall cannot jump many water/flower periods + animAccum = math.min(animAccum + dt, 0.25) + while animAccum >= ANIM_STEP do + animAccum = animAccum - ANIM_STEP + animFrame = animFrame + 1 + end end -- ------------------------------------------------------------------ diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 22155885..2c5ef036 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -11,7 +11,9 @@ local Input = require("src.core.Input") local BindingsMenu = setmetatable({}, { __index = ListMenu }) BindingsMenu.__index = BindingsMenu --- Input.lua's map, primary key first where several keys share a button +-- Input.lua's map, primary key first where several keys share a button. +-- `pad` is the default SDL gamecontroller button (see Input.lua); shown +-- on the SELECT row so controller Back/View is discoverable (#73). local BUTTONS = { { id = "up", label = "UP", key = "up" }, { id = "down", label = "DOWN", key = "down" }, @@ -20,7 +22,7 @@ local BUTTONS = { { id = "a", label = "A", key = "z" }, { id = "b", label = "B", key = "x" }, { id = "start", label = "START", key = "escape" }, - { id = "select", label = "SELECT", key = "rshift" }, + { id = "select", label = "SELECT", key = "tab", pad = "back" }, } -- a binding is a plain key string or { key, pad }; absent = the fixed @@ -32,13 +34,29 @@ local function boundKey(overlay, def) return def.key end +local function boundPad(overlay, def) + local b = overlay and overlay[def.id] + if type(b) == "table" and b.pad then return b.pad end + return def.pad +end + +-- Key column for every row. SELECT also appends "/PAD" (default BACK) +-- so controller Select/View is visible without opening a second legend. +local function boundRight(overlay, def) + local key = boundKey(overlay, def) + if def.id ~= "select" then return key:upper() end + local pad = boundPad(overlay, def) + if pad then return (key .. "/" .. pad):upper() end + return key:upper() +end + function BindingsMenu.new(game) local overlay = game.save and game.save.options and game.save.options.bindings local items = {} for i, def in ipairs(BUTTONS) do items[i] = { label = def.label, - right = boundKey(overlay, def):upper(), button = def } + right = boundRight(overlay, def), button = def } end local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), BindingsMenu) @@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value) end b[slot] = value opts.bindings[item.button.id] = b - item.right = boundKey(opts.bindings, item.button):upper() + item.right = boundRight(opts.bindings, item.button) Input:applyBindings(opts.bindings) if game.writeOptions then game:writeOptions() end end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 1fa6b396..c838e422 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -110,7 +110,7 @@ local function sameRows(_, rows) return rows end -- the vanilla rows as descriptors; each step body is the old per-index -- ladder's, so the save.options mutations are unchanged local function buildRows(game) - return { + local rows = { { id = "textSpeed", label = "TEXT SPEED", value = function(g) return SPEEDS[speedIndex(g)][2] end, step = function(g) @@ -252,6 +252,15 @@ local function buildRows(game) require("src.ui.Screens").push(g, "BindingsMenu") end }, } + -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks + if not GBCFX.isSupported() then + local filtered = {} + for _, row in ipairs(rows) do + if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end + end + rows = filtered + end + return rows end function OptionsMenu.new(game) diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 38310e99..8c455d3e 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -11,6 +11,8 @@ local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Theme = require("src.ui.Theme") +local FieldDefaults = require("src.world.FieldDefaults") +local Map = require("src.world.Map") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -355,8 +357,12 @@ function PartyMenu:update(dt) -- Battle still excludes this list via `not self.battle`. Softboiled -- can appear for a fainted user; its heal transfer then no-ops. if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU — + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" + if mv.id == "FLY" and outside and self.game.save.inventory.THUNDERBADGE then table.insert(items, { label = "FLY", action = "fly" }) elseif mv.id == "FLASH" and ow.dark @@ -375,7 +381,7 @@ function PartyMenu:update(dt) table.insert(items, { label = "STRENGTH", action = "strength" }) elseif mv.id == "SOFTBOILED" then table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) - elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then + elseif mv.id == "TELEPORT" and outside then -- TELEPORT works only OUTDOORS (start_sub_menus.asm -- .teleport -> CheckIfInOutsideMap); dark maps don't -- block it diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6daa739a..a0f6d1fb 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2431,6 +2431,27 @@ function OverworldState:runVictoryHook() if hooks and hooks.onVictory then hooks.onVictory(Game, self) end end +-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads +-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage +-- distance is stored as range<<4 (pixels). There is no tile LOS check for +-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4 +-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4 +-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76). +local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c +local function u8(n) return n % 256 end +local function calcDiff(a, b) + a, b = u8(a), u8(b) + return a >= b and a - b or b - a +end +local function trainerSightPixelDist(npc, player, horizontal) + if horizontal then + return calcDiff(PLAYER_SCREEN_X, + u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16)) + end + return calcDiff(PLAYER_SCREEN_Y, + u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16)) +end + -- STAY trainers with a facing spot the player crossing their line of -- sight (range from the extracted trainer headers), walk up and battle. function OverworldState:checkTrainerSight() @@ -2448,22 +2469,23 @@ function OverworldState:checkTrainerSight() local range = header and header.range or 0 local vec = DIRVEC[npc.facing] if range > 0 and vec then - local dist + local dist, horizontal if vec[1] ~= 0 and npc.cellY == p.cellY then dist = (p.cellX - npc.cellX) * vec[1] + horizontal = true elseif vec[2] ~= 0 and npc.cellX == p.cellX then dist = (p.cellY - npc.cellY) * vec[2] + horizontal = false end - -- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen - -- coordinates only (home/trainers.asm, engine/overworld/ - -- trainer_sight.asm) -- there is no line-of-sight obstruction check. - -- An aligned trainer within range engages through interposed NPCs and - -- unwalkable tiles, and the scripted walk-up below (scriptMove) also - -- ignores collision, so the trainer simply walks/overlaps through - -- anything on the line -- exactly as OAM sprites overlap on hardware. - if dist and dist >= 1 and dist <= range then - self:startTrainerApproach(npc, dist) - return + -- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count: + -- same facing-line rule as before, but the $fc Y quirk excludes the + -- 4-tiles-north tile that cell math would still count as in range. + if dist and dist >= 1 then + local pixelDist = trainerSightPixelDist(npc, p, horizontal) + if pixelDist > 0 and pixelDist <= range * 16 then + self:startTrainerApproach(npc, dist) + return + end end end end @@ -3492,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn) end function OverworldState:drawWorld() - -- advance the water/flower tile animation (runs under dialogs too) + -- advance the water/flower tile animation (runs under dialogs too). + -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate + -- does not speed or slow the cycle (issue #4). require("src.render.TileRenderer").tick() -- let the renderer know whether a spinner puzzle is currently sliding -- the player, so it can flicker the arrow tiles between the blur and diff --git a/src/world/Player.lua b/src/world/Player.lua index 7a1b4454..d966fbb8 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -81,6 +81,14 @@ end -- Advance one fixed step; returns true when a step just completed. function Player:update() + -- land-frame walk pose lasts only through the draw after completion; + -- the next update (idle or a chained step) clears it + self.stepLanded = false + -- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step, + -- not love.draw's display refresh (issue #4: >59fps ended early). + if self.hopFrames and self.hopFrames > 0 then + self.hopFrames = self.hopFrames - 1 + end if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end @@ -109,6 +117,11 @@ function Player:update() self.px, self.py = self.cellX * 16, self.cellY * 16 self.moving = false self.stepFlip = not self.stepFlip + -- keep animClock's pose on this frame (issue #82): bike steps land + -- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to + -- stand whenever moving cleared — a stand flash every tile on the + -- bike, and sometimes after dismount when the clock is desynced + self.stepLanded = true return true end return false @@ -119,7 +132,7 @@ function Player:facingCell() end function Player:walkPhase() - if not self.moving then return 0 end + if not self.moving and not self.stepLanded then return 0 end -- walk frame during the middle of each 16-frame animation cycle local p = (self.animClock or self.progress) % 16 return (p >= 4 and p < 12) and 1 or 0 @@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" } function Player:draw(camX, camY) local py = self.py - -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs + -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs. + -- hopFrames counts down in Player:update (fixed step), never here. if self.hopFrames and self.hopFrames > 0 then - self.hopFrames = self.hopFrames - 1 - local t = 1 - self.hopFrames / (self.hopTotal or 32) + local total = self.hopTotal or 32 + -- update runs before draw, so remaining N means N steps already + -- consumed this hop → t matches the old draw-side post-decrement phase + local t = 1 - self.hopFrames / total py = py - math.floor(10 * math.sin(t * math.pi) + 0.5) -- the shadow stays on the ground under the jumper: one 8x8 tile -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index cb60985d..ff306751 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -620,6 +620,22 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE, "RED++ still has ROUTE (aliased from VIRIDIAN)") check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, "RED++ passes zone colors through like GBC") +-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with +-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias +do + local aliases = PaletteFX.TILE_ALIASES + local roof = aliases and aliases.CELADON_MART_ROOF + local diner = aliases and aliases.CELADON_DINER + check(roof ~= nil and diner ~= nil, + "CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES") + check(diner == roof, + "diner reuses the same lobby table-top alias as the mart roof") + local al = diner and diner[1] + check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a + and al.group == 5 and al.cells[5] and al.cells[6] + and al.cells[9] and al.cells[10], + "lobby table-top alias remaps block 29 cells 5/6/9/10") +end -- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1 -- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red do diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 7eb4f73b..5db78621 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -295,7 +295,8 @@ check(bm.screenId == "BindingsMenu", check(#bm.items == 8, "one row per logical button") check(bm.items[1].label == "UP" and bm.items[1].right == "UP" and bm.items[5].label == "A" and bm.items[5].right == "Z" - and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE", + and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE" + and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK", "with no rebind the rows mirror the fixed map") check(cbGame.save.options.bindings == nil, "opening the screen alone writes nothing") diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 1c4d1d33..af47d595 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -432,6 +432,50 @@ check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength, "fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu") popToOW() +-- =========================================================================== +-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the +-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau +-- building allows Fly like any other outdoor overworld map. +-- =========================================================================== +Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") } +Game.save.inventory = { THUNDERBADGE = true } +ow = pushOW("INDIGO_PLATEAU", 10, 5, "down") +eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset") +local pmIndigo = PartyMenu.new(Game) +Game.stack:push(pmIndigo) +frame({ "a" }) +local actsIndigo = submenuActions(pmIndigo) +check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)") +check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)") +popToOW() + +ow = pushOW("ROUTE_23", 10, 6, "down") +eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset") +local pmR23 = PartyMenu.new(Game) +Game.stack:push(pmR23) +frame({ "a" }) +check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)") +popToOW() + +-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset) +ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down") +check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU", + "Indigo lobby is not an outside tileset") +local pmLobby = PartyMenu.new(Game) +Game.stack:push(pmLobby) +frame({ "a" }) +local actsLobby = submenuActions(pmLobby) +check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby") +check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby") +popToOW() + +-- restore fainted field-move mon for the STRENGTH/SURF cases below +Game.save.party = { fainted } +Game.save.inventory = { + THUNDERBADGE = true, CASCADEBADGE = true, + RAINBOWBADGE = true, SOULBADGE = true, +} + -- STRENGTH activation from a fainted user (name text + strengthActive) ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") clearCaptured() diff --git a/tests/parity_bike_walk_anim.lua b/tests/parity_bike_walk_anim.lua new file mode 100644 index 00000000..1a688825 --- /dev/null +++ b/tests/parity_bike_walk_anim.lua @@ -0,0 +1,97 @@ +-- Regression: bike / desynced walk steps must not flash stand on land (issue #82). +-- +-- walkPhase used to return 0 whenever moving was false. A step clears +-- moving on its final FixedStep tick, so the draw after landing snapped +-- to stand even when animClock was mid walk-cycle. Bike steps are 8 +-- frames, so they land at animClock % 16 == 8 (walk) every tile — always +-- stuttery. Walking after a bike ride inherits a desynced animClock and +-- hit the same stand flash "sometimes." +-- +-- Self-contained; run via `luajit tests/parity_bike_walk_anim.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local Player = require("src.world.Player") +local S = require("tests.harness").suite("parity bike walk anim") +local check, eq = S.check, S.eq + +local function makePlayer(onBike) + local p = Player.new(Data, 5, 5, "down") + p.onBike = onBike + if onBike then + p.stepFramesCur = p.bikeStepFrames or 8 + else + p.stepFramesCur = p.stepFrames or 16 + end + p.animClock = 0 + return p +end + +local function startStep(p) + p.moving = true + p.progress = 0 + p.targetX, p.targetY = p.cellX, p.cellY + 1 +end + +-- --- bike: every land frame mid-cycle must stay walk --- +local bike = makePlayer(true) +local landPhases = {} +for tile = 1, 4 do + startStep(bike) + local stepLen = bike.stepFramesCur + for _ = 1, stepLen do + local done = bike:update() + if done then + landPhases[#landPhases + 1] = bike:walkPhase() + eq(bike.moving, false, "bike step clears moving on land") + check(bike.stepLanded, "bike land latches stepLanded for draw") + end + end +end +-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0 +-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0 +eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose") +eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)") +eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again") +eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again") + +-- continuous bike phases across two tiles: no forced stand in the walk band +bike = makePlayer(true) +local phases = {} +for _ = 1, 2 do + startStep(bike) + for _ = 1, bike.stepFramesCur do + bike:update() + phases[#phases + 1] = bike:walkPhase() + end +end +-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11 +for i = 4, 11 do + eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i) +end + +-- idle after land: next update drops the latch → stand +bike = makePlayer(true) +startStep(bike) +for _ = 1, bike.stepFramesCur do bike:update() end +eq(bike:walkPhase(), 1, "latched land frame still walk") +bike:update() +eq(bike.stepLanded, false, "idle update clears stepLanded") +eq(bike:walkPhase(), 0, "truly idle is stand") + +-- --- walk after desynced animClock (post-bike): land must not force stand --- +local walk = makePlayer(false) +walk.animClock = 8 -- leftover from a bike half-cycle +startStep(walk) +local lastPhase +for _ = 1, walk.stepFramesCur do + walk:update() + lastPhase = walk:walkPhase() +end +eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle") +eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)") + +S.finish() diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua index 585ae082..996c20ab 100644 --- a/tests/parity_gbcfx.lua +++ b/tests/parity_gbcfx.lua @@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0, GBCFX.setLevel(0) +-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK) +check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX") +local prevSystem = love.system +love.system = { getOS = function() return "Android" end } +check(not GBCFX.isSupported(), "Android reports GBC FX unsupported") +GBCFX.setLevel(3) +eq(GBCFX.level, 0, "setLevel forces OFF on Android") +eq(GBCFX.cycle(), 0, "cycle stays OFF on Android") +local opts = { gbcfx = 4 } +check(GBCFX.applyOptions(opts) == true, + "applyOptions reports a cleared persisted level on Android") +eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android") +eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android") +check(not GBCFX.active(), "active() is false on Android") +eq(GBCFX.shader(), nil, "shader() is nil on Android") +love.system = { getOS = function() return "iOS" end } +check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported") +love.system = prevSystem +check(GBCFX.isSupported(), "support restores when OS stub is removed") +-- desktop path still applies a level after leaving the mobile gate +GBCFX.applyOptions({ gbcfx = 2 }) +eq(GBCFX.level, 2, "applyOptions still sets levels on desktop") +GBCFX.setLevel(0) + +-- Options menu hides the GBC FX row on Android +local OptionsMenu = require("src.ui.OptionsMenu") +love.system = { getOS = function() return "Android" end } +local om = OptionsMenu.new({ + data = { rulesets = {}, constants = {} }, + save = { options = {} }, + stack = { pop = function() end }, + input = { wasPressed = function() return false end }, + modStatus = { available = {} }, +}) +local hasGbc = false +for _, row in ipairs(om.rows) do + if row.id == "gbcfx" then hasGbc = true end +end +check(not hasGbc, "Options menu omits GBC FX on Android") +love.system = prevSystem + -- === summary === S.finish() diff --git a/tests/parity_move_swap.lua b/tests/parity_move_swap.lua new file mode 100644 index 00000000..7f4c0996 --- /dev/null +++ b/tests/parity_move_swap.lua @@ -0,0 +1,129 @@ +-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves. +-- +-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults: +-- Tab / either Shift / gamepad Back. Self-contained; also picked up by +-- tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local Input = require("src.core.Input") +local S = require("tests.harness").suite("parity move swap") +local check, eq = S.check, S.eq + +local function freshGame() + local mon = Pokemon.new(Data, "NIDORAN_M", 8) + mon.moves = { + { id = "TACKLE", pp = 35 }, + { id = "LEER", pp = 30 }, + { id = "HORN_ATTACK", pp = 25 }, + { id = "POISON_STING", pp = 35 }, + } + return { + data = Data, + input = Input, + save = { + party = { mon }, + player = { name = "RED" }, + inventory = {}, + options = {}, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } +end + +local function tapKey(battle, key) + Input:keypressed(key) + Input:step() + battle:update(0) + Input:keyreleased(key) +end + +local function tapPad(battle, button) + Input:gamepadpressed(nil, button) + Input:step() + battle:update(0) + Input:gamepadreleased(nil, button) +end + +-- Default Select sources all edge the logical select button. +do + Input:init() + for _, key in ipairs({ "tab", "rshift", "lshift" }) do + Input:reset() + Input:keypressed(key) + Input:step() + check(Input:wasPressed("select"), key .. " maps to select") + end + Input:reset() + Input:gamepadpressed(nil, "back") + Input:step() + check(Input:wasPressed("select"), "gamepad back maps to select") +end + +-- Fight menu: Select, move, Select swaps slots 1 and 2. +do + Input:init() + local game = freshGame() + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.phase = "moveSelect" + battle.moveIndex = 1 + battle.moveSwapIndex = nil + local a = battle.player.curMoves[1].id + local b = battle.player.curMoves[2].id + tapKey(battle, "tab") + eq(battle.moveSwapIndex, 1, "first Select marks the current slot") + tapKey(battle, "down") + eq(battle.moveIndex, 2, "cursor moved to slot 2") + tapKey(battle, "tab") + check(battle.moveSwapIndex == nil, "second Select clears the mark") + eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move") + eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move") + eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync") +end + +-- Same reorder via gamepad Back (SDL "back" = controller Select/View). +do + Input:init() + local game = freshGame() + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.phase = "moveSelect" + battle.moveIndex = 1 + battle.moveSwapIndex = nil + local a = battle.player.curMoves[1].id + local b = battle.player.curMoves[2].id + tapPad(battle, "back") + tapPad(battle, "dpdown") + tapPad(battle, "back") + eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1") + eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2") +end + +-- A confirms a pending swap (bag-style), without starting the turn. +do + Input:init() + local game = freshGame() + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.phase = "moveSelect" + battle.moveIndex = 1 + battle.moveSwapIndex = nil + local a = battle.player.curMoves[1].id + local b = battle.player.curMoves[2].id + tapKey(battle, "tab") + tapKey(battle, "down") + tapKey(battle, "z") -- A + eq(battle.phase, "moveSelect", "A completes a pending swap without attacking") + eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1") + eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2") +end + +S.finish() diff --git a/tests/parity_tower_heal_pad.lua b/tests/parity_tower_heal_pad.lua new file mode 100644 index 00000000..8031c3fe --- /dev/null +++ b/tests/parity_tower_heal_pad.lua @@ -0,0 +1,98 @@ +-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81). +-- +-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript: +-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent +-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player +-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 -> +-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle). +-- +-- Self-contained; run via `luajit tests/parity_tower_heal_pad.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity tower heal pad") +local check, eq = S.check, S.eq + +local M = dofile("data/scripts/story3.lua") +local tower = M.POKEMON_TOWER_5F +check(tower ~= nil and type(tower.onStep) == "function", + "POKEMON_TOWER_5F has an onStep heal-pad trigger") + +local function cmds(rows) + local out = {} + for _, row in ipairs(rows) do out[#out + 1] = row[1] end + return out +end + +local function gameWith(flags) + return { save = { flags = flags or {} } } +end + +local function owWith() + local ran = {} + return { + runner = { + isRunning = function() return false end, + run = function(_, rows) ran[#ran + 1] = rows end, + }, + }, ran +end + +-- ---- 1. pad coords + leave clears latch -------------------------------- +do + local game = gameWith() + local ow, ran = owWith() + check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal") + check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal") + eq(#ran, 0, "no script off the pad") +end + +do + local game = gameWith() + local ow, ran = owWith() + check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals") + check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE") + eq(#ran, 1, "heal script runs once") + + check(tower.onStep(game, ow, 11, 9), + "staying on the pad still consumes the step (BIT_NO_BATTLES)") + eq(#ran, 1, "heal does not re-fire while still on the pad") + + check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false") + check(not game.save.flags.EVENT_IN_PURIFIED_ZONE, + "EVENT_IN_PURIFIED_ZONE resets off the pad") + + check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again") + eq(#ran, 2, "a fresh visit runs the heal script again") +end + +-- ---- 2. all four pad tiles trigger ------------------------------------- +for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do + local game = gameWith() + local ow, ran = owWith() + check(tower.onStep(game, ow, cell[1], cell[2]), + ("pad tile (%d,%d) heals"):format(cell[1], cell[2])) + eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2])) +end + +-- ---- 3. heal sequence matches pokered order ---------------------------- +do + local game = gameWith() + local ow, ran = owWith() + tower.onStep(game, ow, 10, 9) + local rows = ran[1] + check(rows ~= nil, "heal rows captured") + local sequence = table.concat(cmds(rows), ",") + eq(sequence, "heal_party,fade,wait,wait,fade,show_text", + "Tower pad sequence is heal → fade out → Delay3×2 → fade in → text") + eq(rows[2][3], "white", "fades out to white") + eq(rows[3][2], 3, "first Delay3 is 3 frames") + eq(rows[4][2], 3, "second Delay3 is 3 frames") + eq(rows[5][3], "white", "fades in from white") + eq(rows[6][2], "_PokemonTower5FPurifiedZoneText", + "shows the purified-zone text") + for _, row in ipairs(rows) do + check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad") + end +end + +S.finish() diff --git a/tests/parity_trainer_sight.lua b/tests/parity_trainer_sight.lua index 0d18e24d..9dbb9ee7 100644 --- a/tests/parity_trainer_sight.lua +++ b/tests/parity_trainer_sight.lua @@ -169,4 +169,53 @@ do "tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)") end +-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk === +-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER +-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6). +-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell +-- distance 4. Cell math would engage, but pokered's unsigned screen-Y +-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so +-- the trainer must not challenge through that ledge (GitHub #76). +local ROUTE9_BUG_CATCHER = 7 + +local function freshRoute9(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "ROUTE_9", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end + end + return ow, trainer +end + +do + local ow, trainer = freshRoute9(22, 6, "up") + check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2, + "Route 9 Bug Catcher stands at (22,2)") + eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)") + local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER) + eq(header and header.range, 4, "Bug Catcher sight range is 4") + + for _ = 1, 10 do frame(ow) end + check(not ow.engaging, + "distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)") + + -- same plateau, still in pixel range: distance 2 and 3 must engage + local ow2, t2 = freshRoute9(22, 4, "up") + local guard = 0 + while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end + check(ow2.engaging and t2.cellY == 2, + "distance 2 on the upper plateau still engages") + + local ow3 = freshRoute9(22, 3, "up") + guard = 0 + while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end + check(ow3.engaging, "distance 3 on the upper plateau still engages") +end + S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 738d39fb..83edcb96 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1072,6 +1072,81 @@ do mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 }) check(hasText(mh, "Hit the enemy\n5 times!"), "player multi-hit uses _MultiHitText") + -- #85: multi-hit replays PlayMoveAnimation per strike (pokered + -- GetPlayerAnimationType loop), interleaved with each HP drain + do + local anims, seq = 0, {} + for _, r in ipairs(mh.queue) do + if r.anim == "DOUBLESLAP" then + anims = anims + 1 + seq[#seq + 1] = "anim" + elseif r.drain then + seq[#seq + 1] = "drain" + end + end + eq(anims, 5, "multi-hit queues the move animation once per hit") + eq(table.concat(seq, ","), + "anim,drain,anim,drain,anim,drain,anim,drain,anim,drain", + "multi-hit interleaves anim + drain per strike") + for _, r in ipairs(mh.queue) do + if r.anim == "DOUBLESLAP" then + check(r.hit ~= nil, "each multi-hit anim carries hit blink/sfx") + end + end + end + -- #85: crit / effectiveness reprint each strike (.moveDidNotMiss + -- before the GetPlayerAnimationType loop-back). DOUBLE_KICK is a + -- fixed 2-hit Fighting move -- SE vs Normal SNORLAX. + do + local function countText(b, s) + local n = 0 + for _, it in ipairs(b.queue) do + if it.text and it.text:find(s, 1, true) then n = n + 1 end + end + return n + end + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local mhk = BattleState.newWild(Game, "SNORLAX", 40) + mhk.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max roll + mhk:performMove(mhk.player, mhk.enemy, { id = "DOUBLE_KICK", pp = 10 }) + eq(countText(mhk, "It's super\neffective!"), 2, + "multi-hit prints effectiveness once per strike") + local seq = {} + for _, r in ipairs(mhk.queue) do + if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim" + elseif r.drain then seq[#seq + 1] = "drain" + elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se" + elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count" + end + end + eq(table.concat(seq, ","), "anim,drain,se,anim,drain,se,count", + "multi-hit queues SE text between strikes, count after") + + local Runtime = require("src.mods.Runtime") + local Hooks = require("src.mods.Hooks") + local Events = require("src.mods.Events") + local savedE, savedH = Runtime.events, Runtime.hooks + local hooks = Hooks.new() + Runtime.install(Events.new(), hooks) + local unsub = hooks:wrap("battle.crit", function() return true end) + local mhc = BattleState.newWild(Game, "SNORLAX", 40) + mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook + mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 }) + eq(countText(mhc, "Critical hit!"), 2, + "multi-hit prints Critical hit! once per strike") + local cseq = {} + for _, r in ipairs(mhc.queue) do + if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim" + elseif r.drain then cseq[#cseq + 1] = "drain" + elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit" + elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se" + end + end + eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se", + "crit then effectiveness follow each multi-hit drain") + unsub() + Runtime.install(savedE, savedH) + end Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) } local mh2 = BattleState.newWild(Game, "RATTATA", 5) mh2.rng = mkseq({ 7, 0, 255, 255 }) @@ -2611,6 +2686,43 @@ do tostring(c[3]), tostring(c[4]))) end end + +-- == issue #4: ledge hop countdown is fixed-step, not draw-rate == +-- hopFrames used to decrement inside Player:draw, so >59fps ended the +-- arc early and <59fps stretched it. Countdown belongs in update(). +do + local Player = require("src.world.Player") + local p = Player.new(Data, 5, 6, "down") + p.hopFrames, p.hopTotal = 32, 32 + p:draw(0, 0) + p:draw(0, 0) + p:draw(0, 0) + eq(p.hopFrames, 32, "draw does not consume hopFrames") + for _ = 1, 10 do p:update() end + eq(p.hopFrames, 22, "ten fixed updates consume ten hopFrames") + for _ = 1, 22 do p:update() end + eq(p.hopFrames, 0, "hop expires after hopTotal fixed updates") + p:update() + eq(p.hopFrames, 0, "hopFrames stays at 0 once expired") +end + +-- == issue #4: tile anim tick accumulates wall-clock into 60Hz steps == +do + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.setSpinning(true) + local before = TileRenderer.spinBlurActive() + for _ = 1, 8 do TileRenderer.tick(1 / 60) end + check(before ~= TileRenderer.spinBlurActive(), + "tick(1/60) advances water/spinner clock at fixed 60Hz") + local mid = TileRenderer.spinBlurActive() + TileRenderer.tick(1 / 120) + eq(TileRenderer.spinBlurActive(), mid, + "sub-frame dt does not advance the tile anim clock") + TileRenderer.tick(1 / 120) + -- second half-frame completes one step; phase may or may not flip + -- (8-tick blur period), but the clock must have accepted the step + TileRenderer.setSpinning(false) +end end -- ================= BUGS.md batch: border-tree =================