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.
This commit is contained in:
1jamie
2026-09-26 18:00:04 -05:00
parent fab224458f
commit 59c6629457
17 changed files with 1124 additions and 199 deletions
+3
View File
@@ -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
+13 -3
View File
@@ -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
+6 -6
View File
@@ -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
@@ -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
+238 -99
View File
@@ -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
+133 -68
View File
@@ -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
+14 -1
View File
@@ -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()
+24 -12
View File
@@ -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)
+34
View File
@@ -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", {
+4
View File
@@ -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,
+18 -6
View File
@@ -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
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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 ===")
+148
View File
@@ -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
+175
View File
@@ -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()
+197
View File
@@ -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
+113
View File
@@ -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