From 59c66294576748a157bcdddba12e720d019bf21b Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 26 Sep 2026 18:00:04 -0500 Subject: [PATCH] feat(game3): fix held item consumption, cycling road enforcement, door warp arrivals, and optimize field rendering - Fix held item (berries, herbs, pinch stat items) activation and permanent consumption on battler and party data during battles. - Enforce mandatory bicycle riding on Cycling Road (Route 16-18) when dismounting Surf, entering map, loading saves, and prevent bike dismount attempts. - Fix warp and door exit arrivals (e.g. Safari Zone gate) to execute warpExitArrival post-fade step so the player is placed on the correct forward tile and does not bypass triggers. - Optimize field rendering pipeline by caching animated tileset pieces, pooling native metatile cell allocations, and hoisting dynamic module lookups to fast accessors. --- src/core/game3/audio.lua | 3 + src/core/game3/battle/engine.lua | 16 +- src/core/game3/battle/held_items.lua | 12 +- src/core/game3/battle/residual_handlers.lua | 1 - src/core/game3/field_effects.lua | 337 ++++++++++++++------ src/core/game3/field_view.lua | 201 ++++++++---- src/core/game3/item_use.lua | 15 +- src/core/game3/map.lua | 36 ++- src/core/game3/player.lua | 34 ++ src/core/game3/save_schema_firered.lua | 4 + src/core/game3/tileset_anim.lua | 24 +- src/core/game3/warp.lua | 2 +- tests/game3_battle_items_abilities_test.lua | 5 +- tests/game3_cycling_road_bike_test.lua | 148 +++++++++ tests/game3_grass_oam_order_test.lua | 175 ++++++++++ tests/game3_held_items_test.lua | 197 ++++++++++++ tests/game3_safari_zone_warp_exit_test.lua | 113 +++++++ 17 files changed, 1124 insertions(+), 199 deletions(-) create mode 100644 tests/game3_cycling_road_bike_test.lua create mode 100644 tests/game3_grass_oam_order_test.lua create mode 100644 tests/game3_held_items_test.lua create mode 100644 tests/game3_safari_zone_warp_exit_test.lua diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index c4b39041..09a4b4b7 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -461,6 +461,9 @@ function Audio.specialMapSong(sectionId) and Audio.canOverrideMapMusic(Audio.MUS_SURF, sectionId) then return Audio.MUS_SURF end + if P and P.biking and Audio.canOverrideMapMusic(Audio.MUS_CYCLING, sectionId) then + return Audio.MUS_CYCLING + end return Audio._mapSong end diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index a92faa35..b061484f 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -1365,10 +1365,20 @@ end -- pokefirered/src/battle_util.c:1208 function Engine.afterAction(st, ad) if not st or st.over then return end - local first = State.battler(st, State.battlerOrder(st)[1]) or st.player for _ = 1, 4 do - local did = Abilities.runIntimidate(ad) or Abilities.runTrace(ad) - or HeldItems.normal(ad, first, true) or Abilities.forecast(ad) + local did = false + did = did or Abilities.runIntimidate(ad) + did = did or Abilities.runTrace(ad) + for _, id in ipairs(State.battlerOrder(st)) do + local b = State.battler(st, id) + if b and not ad:isFainted(b) then + if HeldItems.normal(ad, b, true) then + did = true + break + end + end + end + did = did or Abilities.forecast(ad) if not did then break end end end diff --git a/src/core/game3/battle/held_items.lua b/src/core/game3/battle/held_items.lua index b8d662d3..d795a8d9 100644 --- a/src/core/game3/battle/held_items.lua +++ b/src/core/game3/battle/held_items.lua @@ -249,7 +249,7 @@ function HeldItems.normal(ad, b, moveTurn) if he == 0 then return false end local hp, maxHp = ad:hp(b), ad:maxHp(b) if he == H.RESTORE_HP then - if hp <= math.floor(maxHp / 2) and not moveTurn then + if hp <= math.floor(maxHp / 2) then local amt = param if hp + param > maxHp then amt = maxHp - hp end heal_berry(ad, b, item, amt) @@ -257,7 +257,7 @@ function HeldItems.normal(ad, b, moveTurn) return true end elseif he == H.RESTORE_PP then - if not moveTurn then return leppa(ad, b, item, param) end + return leppa(ad, b, item, param) elseif he == H.RESTORE_STATS then return white_herb(ad, b, item) elseif he == H.LEFTOVERS then @@ -270,7 +270,7 @@ function HeldItems.normal(ad, b, moveTurn) return true end elseif he >= H.CONFUSE_SPICY and he <= H.CONFUSE_SOUR then - if hp <= math.floor(maxHp / 2) and not moveTurn then + if hp <= math.floor(maxHp / 2) then local flavor = he - H.CONFUSE_SPICY local amt = math.floor(maxHp / math.max(1, param)) if amt == 0 then amt = 1 end @@ -293,12 +293,12 @@ function HeldItems.normal(ad, b, moveTurn) end elseif he >= H.ATTACK_UP and he <= H.SP_DEFENSE_UP then local stat = STAT_ORDER[he - H.ATTACK_UP + 1] - if hp <= math.floor(maxHp / math.max(1, param)) and not moveTurn and (b.stages[stat] or 0) < 6 then + if hp <= math.floor(maxHp / math.max(1, param)) and (b.stages[stat] or 0) < 6 then stat_up(ad, b, item, stat, 1) return true end elseif he == H.CRITICAL_UP then - if hp <= math.floor(maxHp / math.max(1, param)) and not moveTurn and not (b.focusEnergy or b.expFocusEnergy) then + if hp <= math.floor(maxHp / math.max(1, param)) and not (b.focusEnergy or b.expFocusEnergy) then b.focusEnergy = true b.expFocusEnergy = true item_anim(ad, b) @@ -307,7 +307,7 @@ function HeldItems.normal(ad, b, moveTurn) return true end elseif he == H.RANDOM_STAT_UP then - if not moveTurn and hp <= math.floor(maxHp / math.max(1, param)) then + if hp <= math.floor(maxHp / math.max(1, param)) then local any = false for _, s in ipairs(STAT_ORDER) do if (b.stages[s] or 0) < 6 then any = true end diff --git a/src/core/game3/battle/residual_handlers.lua b/src/core/game3/battle/residual_handlers.lua index 032b337a..6d22dcdf 100644 --- a/src/core/game3/battle/residual_handlers.lua +++ b/src/core/game3/battle/residual_handlers.lua @@ -227,7 +227,6 @@ function Handlers.registerAll() Residuals.register("held_items", function(ctx) local HeldItems = require("src.core.game3.battle.held_items") HeldItems.normal(ctx.adapter, ctx.target, false) - HeldItems.normal(ctx.adapter, ctx.target, true) end) -- pokefirered/src/battle_util.c:1208 diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 24b7f78c..ff5c1525 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -45,6 +45,28 @@ local FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES = 64, 64, 5 -- Forward-declared so field-effect starters defined above the body can call it. local play_se +local _M = {} +local function getMod(key, path) + local m = _M[key] + if not m then + m = package.loaded[path] + if not m then + local ok, loaded = pcall(require, path) + if ok then m = loaded end + end + _M[key] = m + end + return m +end + +local function modFieldView() return getMod("FieldView", "src.core.game3.field_view") end +local function modHeal() return getMod("Heal", "src.core.game3.pokecenter_heal") end +local function modShowMon() return getMod("ShowMon", "src.core.game3.field_move_show_mon") end +local function modItemfinder() return getMod("Itemfinder", "src.core.game3.itemfinder") end +local function modAudio() return getMod("Audio", "src.core.game3.audio") end +local function modOwSprites() return getMod("OwSprites", "src.core.game3.ow_sprites") end +local function modRenderer() return getMod("Renderer", "src.render.Renderer") end + local function log(msg) if FieldEffects._logged then return end FieldEffects._logged = true @@ -112,14 +134,15 @@ function FieldEffects.install(cache) FieldEffects._ground = nil FieldEffects._surfClock = 0 FieldEffects._logged = false - local okV, FieldView = pcall(require, "src.core.game3.field_view") - if okV and FieldView and FieldView.setCameraPanning then + local FieldView = modFieldView() + if FieldView and FieldView.setCameraPanning then FieldView.setCameraPanning(0, 0) FieldView.setFlashRadius(nil) end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and Heal.install then Heal.install(cache) end - require("src.core.game3.field_move_show_mon").invalidate() + local Heal = modHeal() + if Heal and Heal.install then Heal.install(cache) end + local ShowMon = modShowMon() + if ShowMon and ShowMon.invalidate then ShowMon.invalidate() end end function FieldEffects.invalidate() @@ -127,14 +150,15 @@ function FieldEffects.invalidate() FieldEffects._fx = nil FieldEffects._anims = {} FieldEffects._ground = nil - local okV, FieldView = pcall(require, "src.core.game3.field_view") - if okV and FieldView and FieldView.setCameraPanning then + local FieldView = modFieldView() + if FieldView and FieldView.setCameraPanning then FieldView.setCameraPanning(0, 0) FieldView.setFlashRadius(nil) end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and Heal.invalidate then Heal.invalidate() end - require("src.core.game3.field_move_show_mon").invalidate() + local Heal = modHeal() + if Heal and Heal.invalidate then Heal.invalidate() end + local ShowMon = modShowMon() + if ShowMon and ShowMon.invalidate then ShowMon.invalidate() end end -- ---------------------------------------------------------------- Tall Grass @@ -246,15 +270,9 @@ function FieldEffects.startRockSmash(targetObj, cx, cy, onDone) table.insert(FieldEffects._anims, anim) end -local function fieldView() - local ok, FieldView = pcall(require, "src.core.game3.field_view") - if ok and type(FieldView) == "table" then return FieldView end - return nil -end - --- pokefirered/src/field_screen_effect.c:194 function FieldEffects.animateFlashLevel(fromLevel, toLevel) - local FieldView = fieldView() + local FieldView = modFieldView() if not FieldView then return nil end local from = FieldView.radiusForLevel(fromLevel) local to = FieldView.radiusForLevel(toLevel) @@ -863,8 +881,8 @@ function FieldEffects.startDestroyDeoxysRock(localId, graphicsId) -- shards start at the rock's own top-left corner (4px higher) and fly apart. -- eo.px/py is the cell's foot point, so undo the offset OwSprites.draw adds. local originX, originY = x - 8, y - 20 - local okO, OwSprites = pcall(require, "src.core.game3.ow_sprites") - if okO and OwSprites and OwSprites.getDraw then + local OwSprites = modOwSprites() + if OwSprites and OwSprites.getDraw then local spr = OwSprites.getDraw(graphicsId) if spr and spr.width and spr.height then originX = x + (16 - spr.width) / 2 @@ -941,8 +959,8 @@ function FieldEffects.startEmote(targetObj, emoteType, onDone) table.insert(FieldEffects._anims, anim) if emoteType == "exclamation" or emoteType == 0 or emoteType == 0x62 or emoteType == "double_exclamation" or emoteType == 1 or emoteType == 0x65 then - local okA, Audio = pcall(require, "src.core.game3.audio") - if okA and Audio and Audio.playSe then + local Audio = modAudio() + if Audio and Audio.playSe then Audio.playSe(21) -- SE_PIN end end @@ -998,8 +1016,8 @@ local function anim_frame(seq, t, loop) end function play_se(id) - local okA, Audio = pcall(require, "src.core.game3.audio") - if okA and Audio and Audio.playSe then Audio.playSe(id) end + local Audio = modAudio() + if Audio and Audio.playSe then Audio.playSe(id) end end -- ------------------------------------------------- setfieldeffectargument plumbing @@ -1496,9 +1514,10 @@ function FieldEffects.step() resolve_waiters() - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and Heal.step then Heal.step() end - require("src.core.game3.field_move_show_mon").step() + local Heal = modHeal() + if Heal and Heal.step then Heal.step() end + local ShowMon = modShowMon() + if ShowMon and ShowMon.step then ShowMon.step() end end -- ---------------------------------------------------------------- Drawing @@ -1586,38 +1605,193 @@ function FieldEffects.drawBehind(camX, camY) end end ---- Draw in front of player (feet cover, cut grass particles, rock smash rubble, bird, ripples) -function FieldEffects.drawFront(camX, camY, playerPy) - camX, camY = camX or 0, camY or 0 +--- Collect ground/feet field effects as sorted actors (OAM Y-ordering with players/NPCs). +function FieldEffects.collectActors(actors) + if not actors then return end - -- 1) Tall grass feet cover + -- 1) Tall grass feet cover (player active effect) local fx = FieldEffects._fx - if fx then - local sheet = load_sheet("tall_grass", 16, 16, 5) - if sheet and sheet.quadsFront then - local drawCover = true - if playerPy ~= nil then - local feetY = playerPy + CELL - local grassTop = fx.cy * CELL - local grassBot = grassTop + CELL - if feetY < grassTop + FEET_H or feetY > grassBot + 2 then - drawCover = false - end + local sheetGrass = load_sheet("tall_grass", 16, 16, 5) + if fx and sheetGrass and sheetGrass.quadsFront then + local P = package.loaded["src.core.game3.player"] + local playerPy = P and P.py + local drawCover = true + if playerPy ~= nil then + local feetY = playerPy + CELL + local grassTop = fx.cy * CELL + local grassBot = grassTop + CELL + if feetY < grassTop + FEET_H or feetY > grassBot + 2 then + drawCover = false end - if drawCover then - local frameIdx = RUSTLE[fx.step + 1] or 0 - local q = sheet.quadsFront[frameIdx] - if q then - local sx = fx.cx * CELL - camX - local sy = fx.cy * CELL - camY + (16 - FEET_H) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, q, sx, sy) + end + if drawCover then + local frameIdx = RUSTLE[fx.step + 1] or 0 + local q = sheetGrass.quadsFront[frameIdx] + if q then + local gx = fx.cx * CELL + local gy = fx.cy * CELL + (16 - FEET_H) + actors[#actors + 1] = { + kind = "field_effect_grass", + elevation = P and P.elevation or 3, + sortY = fx.cy * CELL + 0.5, + x = gx, + y = gy, + i = 90000, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheetGrass.image, q, gx - camX, gy - camY) + end, + } + end + end + end + + -- 2) Grass feet cover for NPCs standing on grass + local Objects = package.loaded["src.core.game3.objects"] + local Collision = package.loaded["src.core.game3.collision"] + if Objects and Objects.forDraw and Collision and Collision.isGrass and sheetGrass and sheetGrass.quadsFront then + local qStatic = sheetGrass.quadsFront[4] + if qStatic then + for _, eo in ipairs(Objects.forDraw()) do + local cx = eo.cellX + local cy = eo.cellY + local isPlayerFx = fx and fx.cx == cx and fx.cy == cy + if not isPlayerFx and cx and cy and Collision.isGrass(cx, cy) then + local npcPy = eo.py or (cy * CELL) + local feetY = npcPy + CELL + local grassTop = cy * CELL + local grassBot = grassTop + CELL + if feetY >= grassTop + FEET_H and feetY <= grassBot + 2 then + local gx = cx * CELL + local gy = cy * CELL + (16 - FEET_H) + actors[#actors + 1] = { + kind = "field_effect_npc_grass", + elevation = eo.elevation or (eo.def and eo.def.elevation) or 3, + sortY = npcPy + 0.5, + x = gx, + y = gy, + i = 90000 + (tonumber(eo.localId) or 0), + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheetGrass.image, qStatic, gx - camX, gy - camY) + end, + } + end end end end end - -- 2) Transient particle animations + -- 3) Ground / actor attached transient animations + local P = package.loaded["src.core.game3.player"] + for idx, anim in ipairs(FieldEffects._anims) do + if anim.kind == "splash" or anim.kind == "feet_water" then + local sheet = load_sheet("splash", 16, 8, 2) + local q = sheet and P and sheet.quads[anim.frame or 0] + if q and P then + local px = P.px + local py = P.py + FEET_H + actors[#actors + 1] = { + kind = "field_effect_splash", + elevation = P.elevation or 3, + sortY = P.py + 0.5, + x = px, + y = py, + i = 91000 + idx, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, px - camX, py - camY) + end, + } + end + elseif anim.kind == "dust" then + local sheet = load_sheet("ground_impact_dust", 16, 8, 3) + local q = sheet and sheet.quads[anim.frame or 0] + if q then + local dx = anim.cx * CELL + local dy = anim.cy * CELL + 8 + actors[#actors + 1] = { + kind = "field_effect_dust", + elevation = 3, + sortY = anim.cy * CELL + 0.5, + x = dx, + y = dy, + i = 91000 + idx, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, dx - camX, dy - camY) + end, + } + end + elseif anim.kind == "hot_springs" then + local sheet = load_sheet("hot_springs_water", 16, 16, 1) + local q = sheet and P and sheet.quads[0] + if q and P then + local px = P.px + local py = P.py + actors[#actors + 1] = { + kind = "field_effect_hot_springs", + elevation = P.elevation or 3, + sortY = P.py + 0.5, + x = px, + y = py, + i = 91000 + idx, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, px - camX, py - camY) + end, + } + end + elseif anim.kind == "cut_tree" then + local sheet = load_sheet("cut_tree", 32, 32, 4) + local f = math.min(3, math.floor((anim.timer or 0) / 6)) + local q = sheet and sheet.quads[f] + if q then + local tx = anim.cx * CELL - 8 + local ty = anim.cy * CELL - 16 + actors[#actors + 1] = { + kind = "field_effect_cut_tree", + elevation = 3, + sortY = anim.cy * CELL, + x = tx, + y = ty, + i = 91000 + idx, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, tx - camX, ty - camY) + end, + } + end + elseif anim.kind == "emote" or anim.kind == "exclamation" then + local sheet = load_sheet("emoticons", 16, 16, 15) + if sheet and sheet.quads[anim.frame] then + local t = anim.targetObj + local ox = t and (t.px or (t.cellX and t.cellX * CELL) or (t.x and t.x * CELL)) or 0 + local oy = t and (t.py or (t.cellY and t.cellY * CELL) or (t.y and t.y * CELL)) or 0 + local sx = ox + local sy = oy - 16 + actors[#actors + 1] = { + kind = "field_effect_emote", + elevation = t and t.elevation or 3, + sortY = oy + 0.5, + x = sx, + y = sy, + i = 91000 + idx, + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, sheet.quads[anim.frame], sx - camX, sy - camY) + end, + } + end + end + end +end + +--- Draw in front of all actors (floating/airborne particles, rock smash rubble, bird) +function FieldEffects.drawFront(camX, camY, playerPy) + camX, camY = camX or 0, camY or 0 + + -- Transient airborne particle animations for _, anim in ipairs(FieldEffects._anims) do if anim.kind == "cut_grass_scatter" then local sheet = load_sheet("cut_grass", 8, 8, 1) @@ -1661,43 +1835,6 @@ function FieldEffects.drawFront(camX, camY, playerPy) end elseif (anim.kind == "fly_out" or anim.kind == "fly_in") and anim.bird then draw_bird(anim.bird, camX, camY) - elseif anim.kind == "emote" or anim.kind == "exclamation" then - local sheet = load_sheet("emoticons", 16, 16, 15) - if sheet and sheet.quads[anim.frame] then - local t = anim.targetObj - local ox = t and (t.px or (t.cellX and t.cellX * CELL) or (t.x and t.x * CELL)) or 0 - local oy = t and (t.py or (t.cellY and t.cellY * CELL) or (t.y and t.y * CELL)) or 0 - local sx = ox - camX - local sy = oy - 16 - camY - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, sheet.quads[anim.frame], sx, sy) - end - elseif anim.kind == "splash" or anim.kind == "feet_water" then - -- pokefirered/src/field_effect_helpers.c:598 FldEff_Splash - local sheet = load_sheet("splash", 16, 8, 2) - local P = package.loaded["src.core.game3.player"] - local q = sheet and P and sheet.quads[anim.frame or 0] - if q then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, q, P.px - camX, P.py + FEET_H - camY) - end - elseif anim.kind == "dust" then - -- pokefirered/src/field_effect_helpers.c:1117 - local sheet = load_sheet("ground_impact_dust", 16, 8, 3) - local q = sheet and sheet.quads[anim.frame or 0] - if q then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, q, anim.cx * CELL - camX, anim.cy * CELL + 8 - camY) - end - elseif anim.kind == "hot_springs" then - -- pokefirered/src/field_effect_helpers.c:777 UpdateHotSpringsWaterFieldEffect - local sheet = load_sheet("hot_springs_water", 16, 16, 1) - local P = package.loaded["src.core.game3.player"] - local q = sheet and P and sheet.quads[0] - if q then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, q, P.px - camX, P.py - camY) - end end end end @@ -1711,8 +1848,8 @@ function FieldEffects.drawOverlay(camX, camY) -- 1) Flash screen illumination for _, anim in ipairs(FieldEffects._anims) do if anim.kind == "flash" and anim.alpha > 0 then - local okR, Renderer = pcall(require, "src.render.Renderer") - if okR and Renderer and Renderer.canvas then + local Renderer = modRenderer() + if Renderer and Renderer.canvas then Renderer.screenVeil = { 1, 1, 1, anim.alpha } else love.graphics.setColor(1, 1, 1, anim.alpha) @@ -1736,10 +1873,12 @@ function FieldEffects.drawOverlay(camX, camY) end end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and Heal.draw then Heal.draw(camX, camY) end - require("src.core.game3.itemfinder").draw() - require("src.core.game3.field_move_show_mon").draw() + local Heal = modHeal() + if Heal and Heal.draw then Heal.draw(camX, camY) end + local Itemfinder = modItemfinder() + if Itemfinder and Itemfinder.draw then Itemfinder.draw() end + local ShowMon = modShowMon() + if ShowMon and ShowMon.draw then ShowMon.draw() end end --- pret dofieldeffect / waitfieldeffect for FLDEFF_POKECENTER_HEAL (25). @@ -1759,8 +1898,8 @@ function FieldEffects.doFieldEffect(id) local localId = FieldEffects.fieldEffectArgument(0, 1) return FieldEffects.startDestroyDeoxysRock(localId) ~= nil end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and id == Heal.FLDEFF then + local Heal = modHeal() + if Heal and id == Heal.FLDEFF then return Heal.start() end return false @@ -1777,8 +1916,8 @@ function FieldEffects.waitFieldEffect(id, done) FieldEffects._waiters[#FieldEffects._waiters + 1] = { id = id, done = done } return end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and id == Heal.FLDEFF then + local Heal = modHeal() + if Heal and id == Heal.FLDEFF then Heal.wait(done) return end @@ -1794,8 +1933,8 @@ function FieldEffects.isFieldEffectActive(id) end return false end - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and id == Heal.FLDEFF then + local Heal = modHeal() + if Heal and id == Heal.FLDEFF then return Heal.isActive() end return false diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index e4cd954e..0135e19a 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -59,6 +59,38 @@ local function screenAnchor(px, py, camX, camY) return (px - camX) - PLAYER_SCREEN_X, (py - camY) - PLAYER_SCREEN_Y end +local _M = {} +local function getMod(key, path) + local m = _M[key] + if not m then + m = package.loaded[path] + if not m then + local ok, loaded = pcall(require, path) + if ok then m = loaded end + end + _M[key] = m + end + return m +end + +local function modObjects() return getMod("Objects", "src.core.game3.objects") end +local function modOwSprites() return getMod("OwSprites", "src.core.game3.ow_sprites") end +local function modNativeTileset() return getMod("NativeTileset", "src.core.game3.tileset_native") end +local function modFieldEffects() return getMod("FieldEffects", "src.core.game3.field_effects") end +local function modSpriteRenderer() return getMod("SpriteRenderer", "src.render.SpriteRenderer") end +local function modPalettes() return getMod("Palettes", "src.world.gen2.Palettes") end +local function modFlags() return getMod("Flags", "src.core.game3.scripting.flags") end +local function modDoors() return getMod("Doors", "src.core.game3.doors") end +local function modHeal() return getMod("Heal", "src.core.game3.pokecenter_heal") end +local function modSSAnne() return getMod("SSAnne", "src.core.game3.ss_anne_cutscene") end +local function modFieldWeather() return getMod("FieldWeather", "src.core.game3.field_weather") end +local function modAssets() return getMod("Assets", "src.render.Assets") end +local function modPlayer() return getMod("Player", "src.core.game3.player") end +local function modGbcPalette() return getMod("GbcPalette", "src.render.GbcPalette") end +local function modSeagallop() return getMod("SeagallopUi", "src.ui.game3.seagallop") end +local function modShopMenu() return getMod("ShopMenu", "src.ui.game3.shop_menu") end +local function modFollower() return getMod("Follower", "src.world.game3.Follower") end + local function log(msg) print("[game3/field] " .. tostring(msg)) end @@ -90,8 +122,8 @@ local function loadAtlas(tileset) return FieldView._atlas end local img - local ok, Assets = pcall(require, "src.render.Assets") - if ok and Assets and Assets.image then + local Assets = modAssets() + if Assets and Assets.image then local aok, aimg = pcall(Assets.image, path) if aok then img = aimg end end @@ -145,8 +177,8 @@ end --- Resolve Sevii special BG palette set (8 slots × 4 RGB). local function resolveBgSet(game, mapDef, daytime) - local ok, Palettes = pcall(require, "src.world.gen2.Palettes") - if not ok or not Palettes then return nil end + local Palettes = modPalettes() + if not Palettes then return nil end local data = game and game.data local pals = data and (data.gen2Palettes or data.palettes) if not pals then return nil end @@ -173,9 +205,9 @@ end local function playerPixels(game) -- Game3 avatar is source of truth while Runtime is active. - local okP, G3Player = pcall(require, "src.core.game3.player") + local G3Player = modPlayer() local Runtime = package.loaded["src.core.game3.runtime"] - if okP and G3Player and Runtime and Runtime.isActive and Runtime.isActive() then + if G3Player and Runtime and Runtime.isActive and Runtime.isActive() then local xOff = G3Player.spriteXOffset or 0 local yOff = G3Player.spriteYOffset or 0 if yOff == 0 and G3Player.jumpSpriteY then @@ -241,16 +273,8 @@ local function playerSpriteName(game) return "SPRITE_CHRIS" end -local PalettesMod, PalettesMissing local function palettes() - if PalettesMod then return PalettesMod end - local loaded = package.loaded["src.world.gen2.Palettes"] - if loaded then PalettesMod = loaded; return loaded end - if PalettesMissing then return nil end - local ok, m = pcall(require, "src.world.gen2.Palettes") - if ok and m then PalettesMod = m; return m end - PalettesMissing = true - return nil + return modPalettes() end local function daytimeFor(game, mapDef) @@ -277,13 +301,13 @@ local function getSpriteRenderer(game, spriteName, seed, objDef, daytime) return nil end - local ok, SpriteRenderer = pcall(require, "src.render.SpriteRenderer") - if not ok or not SpriteRenderer then return nil end + local SpriteRenderer = modSpriteRenderer() + if not SpriteRenderer then return nil end local sr = SpriteRenderer.new(def, seed or spriteName) - local okP, Palettes = pcall(require, "src.world.gen2.Palettes") + local Palettes = modPalettes() local pals = data and (data.gen2Palettes or data.palettes) - if okP and Palettes and pals and sr.setObjPalette then + if Palettes and pals and sr.setObjPalette then local colors = Palettes.spritePalette(pals, daytime or "DAY", def, objDef) if colors then local id = (Palettes.objectPaletteId and Palettes.objectPaletteId(objDef)) @@ -473,12 +497,16 @@ local owOpts = {} local function drawSingleActor(game, mapDef, a, camX, camY) local daytime = daytimeFor(game, mapDef) - local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") - local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() + local OwSprites = modOwSprites() + local useOw = OwSprites and OwSprites.ready and OwSprites.ready() love.graphics.setColor(1, 1, 1, 1) local billboarded = pushBillboard(a.x, a.y, camX, camY) local drew = false - if a.renderer then + if a.draw then + a:draw(camX, camY) + drew = true + end + if not drew and a.renderer then a.renderer:draw(a.x, a.y, camX, camY, a.facing, a.walkPhase or 0, false) drew = true end @@ -518,12 +546,12 @@ local npcActors = setmetatable({}, { __mode = "k" }) local playerActor = {} local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) - local okO, Objects = pcall(require, "src.core.game3.objects") - local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") - local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() + local Objects = modObjects() + local OwSprites = modOwSprites() + local useOw = OwSprites and OwSprites.ready and OwSprites.ready() local actors = frameActors for i = #actors, 1, -1 do actors[i] = nil end - local hasObjects = okO and Objects and Objects.hasMap and Objects.hasMap() + local hasObjects = Objects and Objects.hasMap and Objects.hasMap() if hasObjects then for _, eo in ipairs(Objects.forDraw()) do @@ -640,8 +668,15 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk actors[#actors + 1] = a end - local follower = require("src.world.game3.Follower").actor() + local Follower = modFollower() + local follower = Follower and Follower.actor and Follower.actor() if follower then actors[#actors + 1] = follower end + + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.collectActors then + FieldEffects.collectActors(actors) + end + return applyDrawOrder(actors, frameUnder, frameOver) end @@ -706,8 +741,8 @@ local function draw_pal_list() end local function drawTilesColored(atlas, bySlot, bgSet) - local ok, GbcPalette = pcall(require, "src.render.GbcPalette") - local usePal = ok and GbcPalette and GbcPalette.available and GbcPalette.available() + local GbcPalette = modGbcPalette() + local usePal = GbcPalette and GbcPalette.available and GbcPalette.available() and bgSet and GbcPalette.with love.graphics.setColor(1, 1, 1, 1) @@ -769,8 +804,8 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) local pair = mapDef.pair or (layout and layout.pair) if not layout or not pair then return false end - local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native") - if not (okN and NativeTileset and NativeTileset.ready(pair)) then + local NativeTileset = modNativeTileset() + if not (NativeTileset and NativeTileset.ready and NativeTileset.ready(pair)) then if not FieldView._loggedNativeFallback then log("native unavailable for " .. tostring(pair) .. " — Gen2 atlas fallback") FieldView._loggedNativeFallback = true @@ -811,7 +846,24 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) for r = 0, rows - 1 do FieldView._nativeOverByRow[r] = {} end - local cellsByPair = {} + + local cellsByPair = FieldView._nativeCellsByPair + if not cellsByPair then + cellsByPair = {} + FieldView._nativeCellsByPair = cellsByPair + else + for k, list in pairs(cellsByPair) do + for i = #list, 1, -1 do list[i] = nil end + end + end + + local cellPool = FieldView._nativeCellPool + if not cellPool then + cellPool = {} + FieldView._nativeCellPool = cellPool + end + local poolIdx = 0 + local voidHas, voidPrimary = nil, nil if voidMode ~= "map" and voidMode ~= "black" then voidHas = function(m) return NativeTileset.hasMid(pair, m) end @@ -842,7 +894,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) list = {} cellsByPair[srcPair] = list end - list[#list + 1] = { mid = mid, x = col * CELL, y = row * CELL } + poolIdx = poolIdx + 1 + local c = cellPool[poolIdx] + if not c then + c = {} + cellPool[poolIdx] = c + end + c.mid = mid + c.x = col * CELL + c.y = row * CELL + list[#list + 1] = c end end end @@ -870,7 +931,11 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) end end end - require("src.core.game3.tileset_anim").setVisiblePairs(cellsByPair) + local TilesetAnim = package.loaded["src.core.game3.tileset_anim"] + or require("src.core.game3.tileset_anim") + if TilesetAnim and TilesetAnim.setVisiblePairs then + TilesetAnim.setVisiblePairs(cellsByPair) + end FieldView._nativeBx = cx0 FieldView._nativeBy = cy0 FieldView._nativePair = pair @@ -946,8 +1011,8 @@ end -- pokefirered/src/field_screen_effect.c:194 function FieldView.animateFlashLevel(fromLevel, toLevel) - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.animateFlashLevel then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.animateFlashLevel then return FieldEffects.animateFlashLevel(fromLevel, toLevel) end FieldView.setFlashLevel(toLevel) @@ -970,8 +1035,8 @@ end local function flashActive() local Space = package.loaded["src.core.game3.scripting.space"] if Space and Space.store then - local okF, Flags = pcall(require, "src.core.game3.scripting.flags") - if okF and Flags and Flags.getFlag + local Flags = modFlags() + if Flags and Flags.getFlag and Flags.getFlag(Space.store, nil, FLAG_SYS_FLASH_ACTIVE) then return true end @@ -1096,8 +1161,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) canvasH = canvasH or Display.H opts = opts or {} - local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop") - if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then + local SeagallopUi = modSeagallop() + if SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, 0, canvasW, canvasH) love.graphics.setColor(1, 1, 1, 1) @@ -1142,8 +1207,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) local screenOy = math.floor((canvasH - Display.H) / 2) -- pret BuyMenuDrawMapBg (shop.c:731-734): Frame player & counter in left gap (X: 0..80, Y: 0..160) - local okShop, ShopMenu = pcall(require, "src.ui.game3.shop_menu") - if okShop and ShopMenu and ShopMenu.isShopCamera and ShopMenu.isShopCamera() then + local ShopMenu = modShopMenu() + if ShopMenu and ShopMenu.isShopCamera and ShopMenu.isShopCamera() then local fx, fy = px, py if facing == "up" or facing == "north" then fy = fy - CELL elseif facing == "down" or facing == "south" then fy = fy + CELL @@ -1194,16 +1259,16 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- Tall grass under body (pret lower OAM priority). do - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.drawBehind then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.drawBehind then FieldEffects.drawBehind(camX, camY) end end -- Door opening/closing animation overlays (under actors). do - local okDoors, Doors = pcall(require, "src.core.game3.doors") - if okDoors and Doors and Doors.draw then + local Doors = modDoors() + if Doors and Doors.draw then Doors.draw(camX, camY, canvasW, canvasH) end end @@ -1211,8 +1276,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- pokefirered/src/field_effect.c:910 if not opts.actorsOnly then - local okHeal, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if okHeal and Heal and Heal.drawBalls then + local Heal = modHeal() + if Heal and Heal.drawBalls then local sx, sy = screenAnchor(px, py, camX, camY) love.graphics.push() love.graphics.translate(sx, sy) @@ -1227,8 +1292,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- the actors, for exactly that reason -- a Renderer.screenVeil would cover -- the fragments too and the shatter would be invisible. if not opts.actorsOnly then - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.bgFlashAlpha then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.bgFlashAlpha then local a = FieldEffects.bgFlashAlpha() if a and a > 0 then love.graphics.setColor(1, 1, 1, a) @@ -1240,8 +1305,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- S.S. Anne wake (pret oam.priority = 2, subpriority = 0xFF: under boat hull). if not opts.actorsOnly then - local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene") - if okSS and SSAnne and SSAnne.drawWake then + local SSAnne = modSSAnne() + if SSAnne and SSAnne.drawWake then SSAnne.drawWake(camX, camY) end end @@ -1275,16 +1340,16 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- Tall grass over feet (pret subpriority above avatar). if not opts.actorsOnly then - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.drawFront then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.drawFront then FieldEffects.drawFront(camX, camY, py) end end -- pokefirered/src/field_effect.c:1024 if not opts.actorsOnly then - local okHeal, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if okHeal and Heal and Heal.drawMonitor then + local Heal = modHeal() + if Heal and Heal.drawMonitor then local sx, sy = screenAnchor(px, py, camX, camY) love.graphics.push() love.graphics.translate(sx, sy) @@ -1295,19 +1360,19 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- Pokemon Center heal machine (screen-space OAM, pret FLDEFF_POKECENTER_HEAL). if not opts.actorsOnly then - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.drawOverlay then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.drawOverlay then love.graphics.push() love.graphics.translate(screenOx, screenOy) FieldEffects.drawOverlay(camX, camY) love.graphics.pop() end - local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene") - if okSS and SSAnne and SSAnne.drawSmoke then + local SSAnne = modSSAnne() + if SSAnne and SSAnne.drawSmoke then SSAnne.drawSmoke(camX, camY) end - local okW, FieldWeather = pcall(require, "src.core.game3.field_weather") - if okW and FieldWeather and FieldWeather.draw then + local FieldWeather = modFieldWeather() + if FieldWeather and FieldWeather.draw then FieldWeather.draw(camX, camY, canvasW, canvasH) end end @@ -1339,16 +1404,16 @@ function FieldView.invalidate() FieldView._loggedNativeFallback = false FieldView._flashSpans = nil FieldView._flashSpanR = nil - local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native") - if okN and NativeTileset and NativeTileset.invalidate then + local NativeTileset = modNativeTileset() + if NativeTileset and NativeTileset.invalidate then NativeTileset.invalidate() end - local okO, OwSprites = pcall(require, "src.core.game3.ow_sprites") - if okO and OwSprites and OwSprites.invalidate then + local OwSprites = modOwSprites() + if OwSprites and OwSprites.invalidate then OwSprites.invalidate() end - local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") - if okFx and FieldEffects and FieldEffects.invalidate then + local FieldEffects = modFieldEffects() + if FieldEffects and FieldEffects.invalidate then FieldEffects.invalidate() end end diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index e4d0f042..a8c12c24 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -36,6 +36,14 @@ local function wont_have_effect() return (RomText.box("gText_WontHaveEffect")) end +local function cant_dismount_bike_text() + if RomText.has and RomText.has("gText_CantDismountBike") then + local ok, res = pcall(RomText.box, "gText_CantDismountBike") + if ok and res then return res end + end + return "You can't dismount your BIKE here." +end + -- pokefirered/src/data/pokemon/item_effects.h:80 local HERB_HEAL = { [30] = 50, [31] = 200 } local ITEM_REVIVAL_HERB = 33 @@ -422,13 +430,18 @@ end -- pokefirered/src/item_use.c:253 FieldUseFunc_Bike function ItemUse.useBike(session) + local Player = require("src.core.game3.player") + -- pokefirered/src/item_use.c:261 + if Player.biking and Player.isOnCyclingRoad and Player.isOnCyclingRoad(session) then + return false, "bike", cant_dismount_bike_text() + end + -- pokefirered/src/overworld.c:948 Overworld_IsBikingAllowed local biking = map_header_flag(session, "bikingAllowed") if biking == nil then biking = is_outdoor(session) end if not biking then return false, "bike", not_the_time(session) end - local Player = require("src.core.game3.player") -- pokefirered/src/item_use.c:276 ItemUseOnFieldCB_Bicycle if not Player.biking then pcall(function() diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 61c8cacb..ab10eb12 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -307,21 +307,28 @@ function Map.load(mod, game, mapId, opts) Map.ensureMidLayout(game, mapId, def) Map._def = def Map._currentDef = def + local Player = require("src.core.game3.player") + local curSession = (Runtime and Runtime.getSession and Runtime.getSession()) + or (game and game.session) or session + local onCyclingRoad = Player.isOnCyclingRoad and Player.isOnCyclingRoad(curSession, x, y) + local wasBiking = Player.biking or (curSession and curSession.biking == true) + or (save and save.biking == true) + -- pokefirered/src/overworld.c:878 GetAdjustedInitialTransitionFlags local keepBike = false - do - local Player = require("src.core.game3.player") - if Player.biking then - local allowed = def and def.bikingAllowed - if allowed ~= nil then - -- pokefirered/src/overworld.c:948 Overworld_IsBikingAllowed - keepBike = (tonumber(allowed) or 0) ~= 0 - else - local pair = def and (def.pair or (def.midLayout and def.midLayout.pair)) - keepBike = type(pair) == "string" and pair:find("outdoor", 1, true) ~= nil - end - Player.biking = keepBike + if wasBiking or onCyclingRoad then + local allowed = def and def.bikingAllowed + if allowed ~= nil then + -- pokefirered/src/overworld.c:948 Overworld_IsBikingAllowed + keepBike = (tonumber(allowed) or 0) ~= 0 + else + local pair = def and (def.pair or (def.midLayout and def.midLayout.pair)) + keepBike = type(pair) == "string" and pair:find("outdoor", 1, true) ~= nil end + if onCyclingRoad and not (Player.surfing or Player.surfHopping) then + keepBike = true + end + Player.biking = keepBike end if opts.depth1Connections ~= false then Map.loadNeighborsDepth1(game, def) @@ -348,6 +355,7 @@ function Map.load(mod, game, mapId, opts) session.x = x session.y = y session.facing = facing + session.biking = keepBike end -- Keep save.position current for ferry exit / host save without setMap. @@ -358,6 +366,7 @@ function Map.load(mod, game, mapId, opts) save.position.x = x save.position.y = y save.position.facing = facing + save.biking = keepBike end local Player = require("src.core.game3.player") @@ -373,6 +382,9 @@ function Map.load(mod, game, mapId, opts) Player.reset(x, y, facing) end -- pokefirered/src/overworld.c:2145 SetPlayerAvatarTransitionFlags + if onCyclingRoad and not (Player.surfing or Player.surfHopping) then + keepBike = true + end Player.biking = keepBike Player.syncSavePosition(game) diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index 9f8b73a2..00bc0604 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -510,6 +510,29 @@ local function isCyclingRoadPullDown(beh) and beh <= MB_CYCLING_ROAD_PULL_DOWN_GRASS end +function Player.isOnCyclingRoad(session, x, y) + local cx = x or Player.cellX + local cy = y or Player.cellY + local beh = Collision.behavior and Collision.behavior(cx, cy) + if isCyclingRoadPullDown(beh) then return true end + local Flags = package.loaded["src.core.game3.scripting.flags"] + or package.loaded["src.core.game3.flags"] + or require("src.core.game3.scripting.flags") + local Space = package.loaded["src.core.game3.space"] + local store = (session and session.store) or (Space and Space.store) or (session and type(session) == "table" and session) + if not store then + local Runtime = package.loaded["src.core.game3.runtime"] + local s = Runtime and Runtime.getSession and Runtime.getSession() + store = (s and s.store) or s + end + if Flags and Flags.getFlag and store then + if Flags.getFlag(store, nil, 0x830) == true then -- FLAG_SYS_ON_CYCLING_ROAD + return true + end + end + return false +end + -- pokefirered/src/bike.c:215 GetBikeCollision local function bikeCanMove(game, dir) local d = DELTA[dir] @@ -673,6 +696,7 @@ local function finishStep(game) Player.syncSavePosition(game) -- Surf landing / dismount state transitions + local wasSurfing = Player.surfing or Player.dismounting if Player.surfHopping then Player.surfHopping = false Player.surfing = true @@ -692,6 +716,16 @@ local function finishStep(game) session.x, session.y, session.facing = Player.cellX, Player.cellY, Player.facing end + if wasSurfing and not Player.surfing and not Player.surfHopping then + if Player.isOnCyclingRoad(session, Player.cellX, Player.cellY) then + Player.biking = true + pcall(function() + local Audio = require("src.core.game3.audio") + Audio.bikeMusic(true, true) + end) + end + end + if ModRuntime.wants("world.stepped") then local Map = package.loaded["src.core.game3.map"] ModRuntime.emit("world.stepped", { diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 27c88327..726a6f09 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -332,6 +332,9 @@ function Schema.toSaveTable(session) x = session.x, y = session.y, facing = session.facing, + biking = (session.biking == true) + or (package.loaded["src.core.game3.player"] and package.loaded["src.core.game3.player"].biking == true) + or false, healMap = session.healMap, healX = session.healX, healY = session.healY, @@ -446,6 +449,7 @@ function Schema.fromSaveTable(save) x = save.x or MapIds.NEW_GAME_START.x, y = save.y or MapIds.NEW_GAME_START.y, facing = save.facing or "down", + biking = save.biking == true, healMap = save.healMap, healX = save.healX, healY = save.healY, diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index d6fa4ceb..093d9801 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -96,6 +96,20 @@ function TilesetAnim.setVisiblePairs(visible) for pair in pairs(visible) do TilesetAnim._visible[pair] = true end end +local function get_frame_piece(bank, frame, mi, frameRgba) + if not (love and love.image and love.image.newImageData) then return nil end + bank.pieces = bank.pieces or {} + local pKey = frame * 1000 + mi + local piece = bank.pieces[pKey] + if piece then return piece end + local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8", frameRgba) + if ok and imgData then + bank.pieces[pKey] = imgData + return imgData + end + return nil +end + function TilesetAnim._applyKind(entry, kind, frame) local bank = entry.banks[kind] if not bank or bank.frames < 1 then return end @@ -114,13 +128,11 @@ function TilesetAnim._applyKind(entry, kind, frame) local ax = (slot % cols) * 16 local ay = math.floor(slot / cols) * 16 local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA) + local piece = get_frame_piece(bank, frame, mi, frameRgba) local pasted = false - if love and love.image and love.image.newImageData then - local ok, piece = pcall(love.image.newImageData, 16, 16, "rgba8", frameRgba) - if ok and piece and ts.imageData.paste then - ts.imageData:paste(piece, ax, ay) - pasted = true - end + if piece and ts.imageData.paste then + ts.imageData:paste(piece, ax, ay) + pasted = true end if not pasted then local i = 1 diff --git a/src/core/game3/warp.lua b/src/core/game3/warp.lua index e18afe4d..adbfd9b4 100644 --- a/src/core/game3/warp.lua +++ b/src/core/game3/warp.lua @@ -1113,7 +1113,7 @@ function Warp.request(mod, game, mapId, x, y, facing, opts) Player.setVisible(true) end Doors.reset() - Fade.begin(fromMode, 1, function() + warpExitArrival(game, mapId, x, y, fromMode, function() Warp._busy = false releaseField(Field) end) diff --git a/tests/game3_battle_items_abilities_test.lua b/tests/game3_battle_items_abilities_test.lua index 9c48c640..2725b6ad 100644 --- a/tests/game3_battle_items_abilities_test.lua +++ b/tests/game3_battle_items_abilities_test.lua @@ -155,9 +155,10 @@ do eot(st2, ad2) check(ad2:hp(st2.player) == 51 and st2.player.item == 142, "Sitrus waits until HP <= 1/2") - local st3, ad3 = battle({ item = 139, hp = 10, maxHp = 100 }, { moves = { 33 } }) + local st3, ad3 = battle({ item = 139, hp = 60, maxHp = 100 }, { moves = { 33 } }) use(st3, ad3, st3.enemy, 33) - check(st3.player.item == 139, "HP berries do not trigger mid-turn (moveTurn)") + check(st3.player.item == 0 and st3.playerParty[1].item == nil, "HP berries trigger and are consumed mid-turn when HP drops") + check(ad3:hp(st3.player) > (60 - 35), "Oran Berry healed after taking damage") end print("=== CHERI / LUM / PERSIM at move end ===") diff --git a/tests/game3_cycling_road_bike_test.lua b/tests/game3_cycling_road_bike_test.lua new file mode 100644 index 00000000..5af7d0fe --- /dev/null +++ b/tests/game3_cycling_road_bike_test.lua @@ -0,0 +1,148 @@ +#!/usr/bin/env luajit +-- ROM-free unit tests for Game 3 (FireRed / LeafGreen) Cycling Road forced biking & dismount restrictions. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed, passed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Player = require("src.core.game3.player") +local Flags = require("src.core.game3.scripting.flags") +local ItemUse = require("src.core.game3.item_use") +local Collision = require("src.core.game3.collision") +local RomText = require("src.core.game3.rom_text") + +local session = { + flags = {}, + vars = {}, + store = { flags = {}, vars = {} } +} + +-- 1. Test Player.isOnCyclingRoad +Player.cellX, Player.cellY = 0, 0 +Flags.setFlag(session.store, nil, 0x830, false) +check(not Player.isOnCyclingRoad(session, 0, 0), "isOnCyclingRoad is false by default on neutral tile") + +Flags.setFlag(session.store, nil, 0x830, true) +check(Player.isOnCyclingRoad(session, 0, 0), "isOnCyclingRoad is true when FLAG_SYS_ON_CYCLING_ROAD is set") + +-- 2. Test Player.isOnCyclingRoad via metatile behaviors +Flags.setFlag(session.store, nil, 0x830, false) +Collision.behavior = function(x, y) + if x == 10 and y == 10 then return 0xD0 end -- MB_CYCLING_ROAD_PULL_DOWN + if x == 10 and y == 11 then return 0xD1 end -- MB_CYCLING_ROAD_PULL_DOWN_GRASS + return 0x00 +end + +check(Player.isOnCyclingRoad(session, 10, 10), "isOnCyclingRoad is true for MB_CYCLING_ROAD_PULL_DOWN") +check(Player.isOnCyclingRoad(session, 10, 11), "isOnCyclingRoad is true for MB_CYCLING_ROAD_PULL_DOWN_GRASS") +check(not Player.isOnCyclingRoad(session, 5, 5), "isOnCyclingRoad is false for normal tile without flag") + +-- 3. Test Surf dismount onto Cycling Road forces biking +Flags.setFlag(session.store, nil, 0x830, true) +Player.surfing = true +Player.dismounting = true +Player.biking = false +Player.targetX, Player.targetY = 10, 10 +Player.cellX, Player.cellY = 10, 10 +Player.moving = true +Player.surfHopping = false + +-- Step onto cycling road land and complete step +local game = { session = session, save = { position = {} } } +local Runtime = require("src.core.game3.runtime") +Runtime.session = session + +-- Let's run Player.tick to complete the dismount step +Player.progress = 1 +for _ = 1, 32 do + if not Player.moving then break end + Player.tick(game) +end + +check(Player.biking == true, "dismounting surf onto cycling road forces Player.biking = true") +check(Player.surfing == false, "Player.surfing is false after dismount") + +-- 4. Test Surf dismount onto non-cycling-road land keeps player on foot +Flags.setFlag(session.store, nil, 0x830, false) +Collision.behavior = function() return 0x00 end +Player.surfing = true +Player.dismounting = true +Player.biking = false +Player.targetX, Player.targetY = 0, 0 +Player.cellX, Player.cellY = 0, 0 +Player.moving = true +Player.progress = 1 + +for _ = 1, 32 do + if not Player.moving then break end + Player.tick(game) +end + +check(Player.biking == false, "dismounting surf on normal land leaves Player.biking = false") +check(Player.surfing == false, "Player.surfing is false after normal dismount") + +-- 5. Test ItemUse.useBike on Cycling Road (cannot dismount) +session.map = "FR_PALLET_TOWN" +Flags.setFlag(session.store, nil, 0x830, true) +Player.biking = true +local ok, kind, text = ItemUse.useBike(session) +check(ok == false, "useBike returns false when trying to dismount on Cycling Road") +check(Player.biking == true, "Player.biking remains true after attempted dismount") +check(text ~= nil, "refusal text returned when trying to dismount on Cycling Road") + +-- 6. Test ItemUse.useBike when on foot on Cycling Road (can mount) +Player.biking = false +local ok2, kind2, text2 = ItemUse.useBike(session) +check(ok2 == true, "useBike allows mounting when on foot on Cycling Road") +check(Player.biking == true, "Player.biking becomes true after mounting") + +-- 7. Test ItemUse.useBike on normal outdoor land (can dismount and mount freely) +Flags.setFlag(session.store, nil, 0x830, false) +Player.biking = true +local ok3, kind3, text3 = ItemUse.useBike(session) +check(ok3 == true, "useBike allows dismounting on normal land") +check(Player.biking == false, "Player.biking is now false") + +-- 8. Test save & load / Map.load on Cycling Road forces Player.biking = true +local Map = require("src.core.game3.map") +local Audio = require("src.core.game3.audio") +local Schema = require("src.core.game3.save_schema_firered") + +-- Simulate fresh boot / uninitialized avatar state: +Player.biking = false +Flags.setFlag(session.store, nil, 0x830, true) +local cyclingMapDef = { + id = "FR_ROUTE_17", + bikingAllowed = 1, + music = 282, + regionMapSectionId = 44, + pair = "outdoor", +} +game.data = { maps = { FR_ROUTE_17 = cyclingMapDef } } +game.session = session +local Space = require("src.core.game3.scripting.space") +Space.activate = function() end +Space.runEnterScripts = function() end + +Map.load(nil, game, "FR_ROUTE_17", { x = 10, y = 10, facing = "down" }) +check(Player.biking == true, "loading/continuing map on Cycling Road forces Player.biking = true") +check(Audio.specialMapSong() == Audio.MUS_CYCLING, "specialMapSong returns Audio.MUS_CYCLING when biking") + +-- 9. Test Schema serialization preserves biking state +local saveTable = Schema.toSaveTable(session) +check(saveTable.biking == true, "Schema.toSaveTable includes biking = true") +local restoredSession = Schema.fromSaveTable(saveTable) +check(restoredSession.biking == true, "Schema.fromSaveTable restores biking = true") + +print(string.format("=== RESULTS: %d passed, %d failed ===", passed, failed)) +if failed > 0 then os.exit(1) end + diff --git a/tests/game3_grass_oam_order_test.lua b/tests/game3_grass_oam_order_test.lua new file mode 100644 index 00000000..fd72b876 --- /dev/null +++ b/tests/game3_grass_oam_order_test.lua @@ -0,0 +1,175 @@ +-- Test game3 OAM draw ordering for tall grass and trainers (NPCs). +-- Ensures grass cover on tile cy is drawn after the actor on tile cy, +-- but BEFORE any actor on tile cy + 1 (so the lower actor's head is not covered by grass). + +local Objects = require("src.core.game3.objects") +local FieldEffects = require("src.core.game3.field_effects") +local FieldView = require("src.core.game3.field_view") +local Collision = require("src.core.game3.collision") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function done() + if failed > 0 then + print("[test] FAILED " .. failed) + os.exit(1) + end + print("[test] all passed") + os.exit(0) +end + +local CELL = 16 + +-- Mock love graphics image/quad for tall_grass if love is stubbed +FieldEffects._sheets["tall_grass"] = { + image = {}, + quads = { [0] = {}, [1] = {}, [2] = {}, [3] = {}, [4] = {} }, + quadsFront = { [0] = {}, [1] = {}, [2] = {}, [3] = {}, [4] = {} }, + fw = 16, + fh = 16, + frames = 5, +} + +print("[test] 1. Player in grass above NPC trainer: grass cover must sort between Player and Trainer") +do + -- Player at cell (4, 4) -> px = 64, py = 64 + local Player = require("src.core.game3.player") + Player.cellX = 4 + Player.cellY = 4 + Player.px = 4 * CELL + Player.py = 4 * CELL + Player.elevation = 3 + Player.moving = false + + -- Trainer NPC at cell (4, 5) -> px = 64, py = 80 + local trainer = { + localId = 1, + def = { x = 4, y = 5, elevation = 3 }, + cellX = 4, cellY = 5, + px = 4 * CELL, py = 5 * CELL, + elevation = 3, + } + Objects._byId = { [1] = trainer } + Objects.forDraw = function() return { trainer } end + + -- Activate player grass rustle/cover at (4, 4) + FieldEffects.tallGrassAt(4, 4, true) + + local actors = {} + -- Player actor + actors[#actors + 1] = { + kind = "player", + elevation = 3, + x = Player.px, + y = Player.py, + sortY = Player.py, + } + -- Trainer actor + actors[#actors + 1] = { + kind = "npc", + i = 1, + obj = trainer.def, + eventObject = trainer, + elevation = 3, + x = trainer.px, + y = trainer.py, + sortY = trainer.py, + } + + -- Collect field effect actors + FieldEffects.collectActors(actors) + + check(#actors >= 3, "collected Player, Trainer, and Grass Cover actors") + + local under, over = FieldView.applyDrawOrder(actors) + check(#under >= 3, "all actors at elevation 3 land in underActors") + + -- Find positions of player, grass cover, and trainer in sorted order + local playerIdx, grassIdx, trainerIdx + for idx, a in ipairs(under) do + if a.kind == "player" then playerIdx = idx end + if a.kind == "field_effect_grass" then grassIdx = idx end + if a.kind == "npc" and a.i == 1 then trainerIdx = idx end + end + + check(playerIdx ~= nil, "player present in draw list") + check(grassIdx ~= nil, "player grass cover present in draw list") + check(trainerIdx ~= nil, "trainer present in draw list") + + check(playerIdx < grassIdx, "Player (Y=64) drawn before player's grass cover (Y=64.5)") + check(grassIdx < trainerIdx, "Player's grass cover (Y=64.5) drawn before Trainer below (Y=80)") +end + +print("[test] 2. Trainer NPC on grass tile also gets grass cover sorted after trainer") +do + -- Mock Collision.isGrass to return true for cell (4, 5) + local origIsGrass = Collision.isGrass + Collision.isGrass = function(cx, cy) + return (cx == 4 and cy == 4) or (cx == 4 and cy == 5) + end + + local Player = require("src.core.game3.player") + Player.cellX = 4 + Player.cellY = 4 + Player.px = 4 * CELL + Player.py = 4 * CELL + Player.elevation = 3 + Player.moving = false + + local trainer = { + localId = 1, + def = { x = 4, y = 5, elevation = 3 }, + cellX = 4, cellY = 5, + px = 4 * CELL, py = 5 * CELL, + elevation = 3, + } + Objects.forDraw = function() return { trainer } end + FieldEffects.tallGrassAt(4, 4, true) + + local actors = {} + actors[#actors + 1] = { + kind = "player", + elevation = 3, + x = Player.px, + y = Player.py, + sortY = Player.py, + } + actors[#actors + 1] = { + kind = "npc", + i = 1, + obj = trainer.def, + eventObject = trainer, + elevation = 3, + x = trainer.px, + y = trainer.py, + sortY = trainer.py, + } + + FieldEffects.collectActors(actors) + + local under, over = FieldView.applyDrawOrder(actors) + local playerIdx, playerGrassIdx, trainerIdx, trainerGrassIdx + for idx, a in ipairs(under) do + if a.kind == "player" then playerIdx = idx end + if a.kind == "field_effect_grass" then playerGrassIdx = idx end + if a.kind == "npc" and a.i == 1 then trainerIdx = idx end + if a.kind == "field_effect_npc_grass" then trainerGrassIdx = idx end + end + + check(playerIdx < playerGrassIdx, "1. Player drawn first") + check(playerGrassIdx < trainerIdx, "2. Player grass cover drawn second (before trainer)") + check(trainerIdx < trainerGrassIdx, "3. Trainer drawn third (head covers player's grass tile)") + check(trainerGrassIdx > trainerIdx, "4. Trainer grass cover drawn fourth (covers trainer's feet)") + + Collision.isGrass = origIsGrass +end + +done() diff --git a/tests/game3_held_items_test.lua b/tests/game3_held_items_test.lua new file mode 100644 index 00000000..3cdfae2a --- /dev/null +++ b/tests/game3_held_items_test.lua @@ -0,0 +1,197 @@ +#!/usr/bin/env luajit +-- ROM-free comprehensive unit tests for Game 3 (FireRed / LeafGreen) held item behaviors: +-- HP restore berries, pinch stat berries, status berries, herbs, leftovers, focus band, etc. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed, passed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Pokemon = require("src.core.game3.pokemon") +Pokemon._names = { [1] = "BULBASAUR", [4] = "CHARMANDER", [7] = "SQUIRTLE", [25] = "PIKACHU" } +Pokemon._types = { [1] = { 12, 3 }, [4] = { 10, 10 }, [7] = { 11, 11 }, [25] = { 13, 13 } } +Pokemon._stats = { + [1] = { hp = 45, atk = 49, def = 49, spe = 45, spa = 65, spd = 65 }, + [4] = { hp = 39, atk = 52, def = 43, spe = 65, spa = 60, spd = 50 }, + [7] = { hp = 44, atk = 48, def = 65, spe = 43, spa = 50, spd = 64 }, + [25] = { hp = 35, atk = 55, def = 40, spe = 90, spa = 50, spd = 50 }, +} +Pokemon._abilities = { [1] = { 65, 65 }, [4] = { 66, 66 }, [7] = { 67, 67 }, [25] = { 9, 9 } } + +local RomText = require("src.core.game3.rom_text") +RomText.plain = function(key) return tostring(key) end +RomText.box = function(key) return tostring(key) end +RomText.ascii = function(key) return tostring(key) end +RomText.ir = function(key) return {} end + +local BattleText = require("src.core.game3.battle.battle_text") +BattleText.get = function(id, fill) + fill = fill or {} + return string.format("[%s: item=%s buff1=%s]", id, tostring(fill.lastItem or ""), tostring(fill.buff1 or "")) +end + +local Moves = require("src.core.game3.battle.moves") +local ROM_MOVES = { + [1] = { name = "POUND", effect = 0, power = 40, type = 0, accuracy = 100, pp = 35, secondaryChance = 0, target = 0, priority = 0, flags = 51 }, + [33] = { name = "TACKLE", effect = 0, power = 35, type = 0, accuracy = 95, pp = 35, secondaryChance = 0, target = 0, priority = 0, flags = 51 }, + [45] = { name = "GROWL", effect = 18, power = 0, type = 0, accuracy = 100, pp = 40, secondaryChance = 0, target = 8, priority = 0, flags = 22 }, + [86] = { name = "THUNDER WAVE", effect = 67, power = 0, type = 13, accuracy = 100, pp = 20, secondaryChance = 0, target = 0, priority = 0, flags = 22 }, +} +for id, row in pairs(ROM_MOVES) do row.numId = id end +Moves._romLoaded = true +Moves._rom = ROM_MOVES +Moves._names = { [1] = "POUND", [33] = "TACKLE", [45] = "GROWL", [86] = "THUNDER WAVE" } +Pokemon._moveNames = Moves._names +Pokemon._romMoveNames = Moves._names + +local State = require("src.core.game3.battle.state") +local Engine = require("src.core.game3.battle.engine") +local Adapter = require("src.core.game3.battle.adapter") +local HeldItems = require("src.core.game3.battle.held_items") +local Damage = require("src.core.game3.battle.damage") + +local function mon(o) + return { + species = o.species or 1, level = o.level or 50, + hp = o.hp or 100, maxHp = o.maxHp or 100, + attack = o.attack or 50, defense = o.defense or 50, + spAtk = o.spAtk or 50, spDef = o.spDef or 50, + speed = o.speed or 50, ability = o.ability or 0, + nickname = o.nickname, moves = o.moves or { 33 }, + pp = o.pp or { 20, 20, 20, 20 }, maxPp = o.maxPp, + status = o.status, item = o.item, heldItem = o.item, + gender = o.gender, personality = o.personality or 0, + } +end + +local function battle(p, e) + local party = { mon(p) } + local foeParty = { mon(e) } + local st = State.new({ wild = true, playerParty = party, foeParty = foeParty }) + st.rng = function(lo, hi) return lo end + local ad = Adapter.new(st) + return st, ad +end + +local function useMove(st, ad, user, move, slot) + local target = (user == st.player) and st.enemy or st.player + local out = {} + Engine.resolveMove(user, target, move, slot or 1, ad, st, out) + return out +end + +local function eot(st, ad) + return Engine.collectResidualEvents(st, ad) +end + +print("=== 1. Oran Berry mid-turn damage consumption ===") +do + -- Player starts with Oran Berry (139) at 60/100 HP. + -- Enemy attacks with Tackle (power 35). + local st, ad = battle({ item = 139, hp = 60, maxHp = 100 }, { moves = { 33 } }) + check(st.player.item == 139, "player battler holds Oran Berry before attack") + check(st.playerParty[1].item == 139, "player party mon holds Oran Berry before attack") + + useMove(st, ad, st.enemy, 33) + + -- After taking damage, HP dropped below 50, berry activated, healed 10 HP, and was consumed. + check(st.player.item == 0, "player battler item is cleared (0)") + check(st.playerParty[1].item == nil, "player party mon item is nil") + check(st.playerParty[1].heldItem == nil, "player party mon heldItem is nil") + check(st.playerSide.expUsedHeldItem == 139, "expUsedHeldItem recorded for Recycle") +end + +print("=== 2. Sitrus Berry mid-turn damage consumption ===") +do + -- Sitrus Berry (142) heals 30 HP when HP <= 50% + local st, ad = battle({ item = 142, hp = 60, maxHp = 100 }, { moves = { 33 } }) + useMove(st, ad, st.enemy, 33) + check(st.player.item == 0, "Sitrus Berry consumed on battler") + check(st.playerParty[1].item == nil, "Sitrus Berry consumed on party mon") + check(st.playerSide.expUsedHeldItem == 142, "Sitrus Berry recorded for Recycle") +end + +print("=== 3. Enemy mon consuming held berry mid-turn ===") +do + -- Foe holds Oran Berry, player attacks foe + local st, ad = battle({ moves = { 33 } }, { item = 139, hp = 60, maxHp = 100 }) + check(st.enemy.item == 139, "enemy battler holds Oran Berry") + check(st.foeParty[1].item == 139, "enemy party mon holds Oran Berry") + + useMove(st, ad, st.player, 33) + + check(st.enemy.item == 0, "enemy battler item cleared after consumption") + check(st.foeParty[1].item == nil, "enemy party mon item cleared after consumption") + check(st.enemySide.expUsedHeldItem == 139, "enemy side recorded used held item") +end + +print("=== 4. Status curing berries (Cheri / Pecha / Lum) at move end ===") +do + -- Cheri Berry (133) cures paralysis immediately when Thunder Wave is used + local st, ad = battle({ item = 133 }, { moves = { 86 } }) + useMove(st, ad, st.enemy, 86) + check(ad:status(st.player) == nil, "paralysis was cured immediately") + check(st.player.item == 0, "Cheri Berry was consumed") + check(st.playerParty[1].item == nil, "party mon Cheri Berry consumed") +end + +print("=== 5. Stat boosting pinch berries (Liechi Berry) mid-turn ===") +do + -- Liechi Berry (168) boosts Attack when HP <= 25% (1/4 maxHp) + local st, ad = battle({ item = 168, hp = 30, maxHp = 100 }, { moves = { 33 } }) + useMove(st, ad, st.enemy, 33) + check(st.player.stages.attack == 1, "Attack rose by 1 stage from Liechi Berry") + check(st.player.item == 0, "Liechi Berry consumed on battler") + check(st.playerParty[1].item == nil, "Liechi Berry consumed on party mon") +end + +print("=== 6. White Herb restores stat drop mid-turn ===") +do + -- White Herb (180) restores lowered stats when Growl is used + local st, ad = battle({ item = 180 }, { moves = { 45 } }) + useMove(st, ad, st.enemy, 45) + check(st.player.stages.attack == 0, "Attack stat stage restored to 0 by White Herb") + check(st.player.item == 0, "White Herb consumed on battler") + check(st.playerParty[1].item == nil, "White Herb consumed on party mon") +end + +print("=== 7. Leftovers activates only at end of turn (not mid-turn) ===") +do + -- Leftovers (200) heals at end of turn, not during move resolution + local st, ad = battle({ item = 200, hp = 80, maxHp = 100 }, { moves = { 45 } }) + -- Growl does no damage + useMove(st, ad, st.enemy, 45) + check(st.player.mon.hp == 80, "Leftovers did not trigger mid-turn") + check(st.player.item == 200, "Leftovers is not consumed") + + eot(st, ad) + check(st.player.mon.hp == 86, "Leftovers healed maxHp/16 (6 HP) at end of turn") + check(st.player.item == 200, "Leftovers remains held after end of turn") +end + +print("=== 8. Leppa Berry restores PP ===") +do + -- Leppa Berry (138) restores 10 PP when a move is at 0 PP + local st, ad = battle({ item = 138, moves = { 33 }, pp = { 0 }, maxPp = { 35 } }, {}) + Engine.afterAction(st, ad) + check(st.player.mon.pp[1] == 10, "Leppa Berry restored 10 PP") + check(st.player.item == 0, "Leppa Berry consumed") + check(st.playerParty[1].item == nil, "party mon Leppa Berry consumed") +end + +print("-----------------------------------------") +if failed > 0 then + print(string.format("[result] %d test(s) FAILED, %d passed", failed, passed)) + os.exit(1) +else + print(string.format("[result] ALL %d tests PASSED", passed)) + os.exit(0) +end diff --git a/tests/game3_safari_zone_warp_exit_test.lua b/tests/game3_safari_zone_warp_exit_test.lua new file mode 100644 index 00000000..2c7fb752 --- /dev/null +++ b/tests/game3_safari_zone_warp_exit_test.lua @@ -0,0 +1,113 @@ +#!/usr/bin/env luajit +-- Tests Safari Zone warp exit positioning, door step transitions, and trigger line parity against pret pokefirered. +-- pokefirered/src/field_fadetransition.c:242 SetUpWarpExitTask / Task_ExitNonAnimDoor +-- pokefirered/data/maps/FuchsiaCity_SafariZone_Entrance/map.json +-- pokefirered/data/maps/FuchsiaCity_SafariZone_Entrance/scripts.inc + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed, passed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local GameVersion = require("src.core.GameVersion") +GameVersion.set("firered") + +local Collision = require("src.core.game3.collision") +local Player = require("src.core.game3.player") +local Warp = require("src.core.game3.warp") +local Safari = require("src.core.game3.safari") + +print("[test] 1. Metatile behavior constants match pret pokefirered") +check(Collision.isNonAnimDoor(0x60) == true, "MB_CAVE_DOOR (0x60) is recognized as non-anim door") +check(Collision.isWarpDoor(0x69) == true, "MB_WARP_DOOR (0x69) is recognized as warp door") +check(Collision.isNonAnimDoor(0x69) == false, "MB_WARP_DOOR is not non-anim door") + +print("[test] 2. warpExitArrival steps player 1 tile down on MB_CAVE_DOOR") +local fakeFade = { + begin = function(mode, speed, callback) + if callback then callback() end + end, + isActive = function() return false end, +} +package.loaded["src.ui.game3.fade"] = fakeFade + +-- Initialize player at warp landing tile (4, 1) facing down +Player.cellX = 4 +Player.cellY = 1 +Player.facing = "down" +check(Player.cellX == 4 and Player.cellY == 1, "player starts at (4,1)") +check(Player.facing == "down", "player facing is down") + +-- Mock collision behavior at (4, 1) to be MB_CAVE_DOOR (0x60) +local origBehavior = Collision.behavior +Collision.behavior = function(x, y) + if x == 4 and y == 1 then return 0x60 end + return 0 +end + +local finished = false +Warp.fadeModes = function() return 1, 0 end +-- Call warpExitArrival +local warpDone = false +-- In test environment without love.timer, player forceStep completes via step loop +Player.forceStep = function(dir, onDone) + local dirs = { down = {0, 1}, up = {0, -1}, left = {-1, 0}, right = {1, 0} } + local d = dirs[dir] or {0, 0} + Player.cellX = Player.cellX + d[1] + Player.cellY = Player.cellY + d[2] + if onDone then onDone() end + return true +end + +-- Require warp and execute warpExitArrival (using internal function through Warp.request simulation) +local ok, err = pcall(function() + local mockMod = {} + local mockGame = { + data = { maps = {} }, + currentMap = Safari.EXIT_MAP, + } + local Map = require("src.core.game3.map") + Map.load = function(mod, game, mapId, opts) + Player.cellX = opts.x + Player.cellY = opts.y + Player.facing = opts.facing + return true + end + + Warp.request(mockMod, mockGame, Safari.EXIT_MAP, 4, 1, "down", { fade = true }) +end) + +check(ok == true, "Warp.request completed successfully") +check(Player.cellX == 4 and Player.cellY == 2, + string.format("Player stepped down out of doorway from (4,1) to (4,2) (got %d,%d)", Player.cellX, Player.cellY)) + +print("[test] 3. Exit script movement from (4,2) lands player at (4,4) outside triggers") +-- FuchsiaCity_SafariZone_Entrance_Movement_Exit has 2 walk_down steps +-- Step 1: (4,2) -> (4,3) +Player.cellY = Player.cellY + 1 +-- Step 2: (4,3) -> (4,4) +Player.cellY = Player.cellY + 1 +check(Player.cellX == 4 and Player.cellY == 4, + string.format("Player lands at (4,4) after Movement_Exit (got %d,%d)", Player.cellX, Player.cellY)) + +-- Walking North from (4,4) steps onto (4,3), which triggers EntryTriggerMid +local stepNorthDestY = Player.cellY - 1 +check(stepNorthDestY == 3, "Stepping North from (4,4) steps onto coord trigger line y=3") + +Collision.behavior = origBehavior + +print(string.format("\n%d passed, %d failed", passed, failed)) +if failed == 0 then + print("SAFARI_WARP_EXIT_TEST PASS") + os.exit(0) +else + os.exit(1) +end