diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 78311903..eaed4d31 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1124,13 +1124,20 @@ end -- flat launcher meta line needs. function SaveData.slotSummary(save) if type(save) ~= "table" then return nil, nil end - local name = save.player and save.player.name or nil + local name = (save.player and save.player.name) or save.name or save.playerName or nil -- A Gen 2 slot carries no badge items and fills pokedex.caught, so both -- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461). local vinfo = type(save.version) == "string" and GameVersion.info(save.version) local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false + local gen3 = save.generation == 3 or (vinfo and vinfo.generation == 3) or (save.engine == "game3") or (save.version == "firered") or false local dexCount = 0 - if gen2 then + if gen3 then + local dex = save.dex or save.pokedex or {} + local owned = dex.owned or dex.caught or {} + for _, has in pairs(owned) do + if has then dexCount = dexCount + 1 end + end + elseif gen2 then for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do if has then dexCount = dexCount + 1 end end @@ -1140,11 +1147,10 @@ function SaveData.slotSummary(save) end end -- playTime is a plain seconds count in a Gen 1 save but a - -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching - -- the cart's wGameTime* bytes. The launcher calls slotSummary on EVERY - -- version's slot, so this has to read both shapes or the whole launcher - -- crashes the moment a Gold save exists (math.floor on the table). - local pt = save.playTime + -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) or Gen 3 (FireRed) save, + -- matching the cart's time structure. The launcher calls slotSummary on EVERY + -- version's slot, so this has to read both shapes. + local pt = save.playTime or save.playtime or (save.player and save.player.playtime) local t if type(pt) == "table" then t = (tonumber(pt.hours) or 0) * 3600 @@ -1156,7 +1162,25 @@ function SaveData.slotSummary(save) local timeText = ("%d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60) local badges - if gen2 then + if gen3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + badges = 0 + if okF and Flags and Flags.BADGES then + for _, b in ipairs(Flags.BADGES) do + if Flags.getFlag(save, nil, b.flag) or Flags.getFlag(save, nil, b.name) then + badges = badges + 1 + end + end + elseif type(save.flags) == "table" then + for id = 0x820, 0x827 do + if save.flags[id] or save.flags[tostring(id)] then badges = badges + 1 end + end + end + if badges == 0 and (save.badges or (save.player and save.player.badges)) then + local bList = save.badges or (save.player and save.player.badges) or {} + for _, has in pairs(bList) do if has then badges = badges + 1 end end + end + elseif gen2 then -- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto -- (engine/menus/intro_menu.asm:461-469). badges = 0 diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index c861a79f..86b9c403 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -359,15 +359,33 @@ end --- Can the avatar enter cell (tx, ty) on foot? -- Returns ok, reason ("bounds"|"tile"|"entity"|"water"|nil) -function Collision.canEnter(game, tx, ty, _opts) +function Collision.canEnter(game, tx, ty, opts) + opts = opts or {} + local surfing = opts.surfing + if surfing == nil then + local P = package.loaded["src.core.game3.player"] + surfing = P and P.surfing == true + end + -- Prefer owned grid; fall back to host map if unbound. if Collision._grid then if not Collision.inBounds(tx, ty) then return false, "bounds" end if overrideBlocks(tx, ty) then return false, "tile" end if entityBlocks(game, tx, ty) then return false, "entity" end - if Collision.isWater(tx, ty) then return false, "water" end - if not Collision.isWalkable(tx, ty) then return false, "tile" end - return true, nil + local isW = Collision.isWater(tx, ty) + if surfing then + if isW then + return true, nil + else + -- Dismount onto land: verify land tile is walkable + if not Collision.isWalkable(tx, ty) then return false, "tile" end + return true, nil + end + else + if isW then return false, "water" end + if not Collision.isWalkable(tx, ty) then return false, "tile" end + return true, nil + end end local world = hostWorld(game) @@ -477,26 +495,27 @@ function Collision.isDoorWarp(game, cx, cy) local destMap, destX, destY = resolveDest(game, w) if not destMap then return nil end - local coll = Collision.cell(cx, cy) local curMap = (game and game.currentMap) or (Collision._mapId) - local curUpper = string.upper(tostring(curMap or "")) - local destUpper = string.upper(tostring(destMap or "")) - - local isDoor = (coll == 0x71) or (not isBuilding(curUpper) and isBuilding(destUpper)) - if isDoor then - return { - warp = w, - destMap = destMap, - destX = destX, - destY = destY, - x = cx, - y = cy, - } + local okDoors, Doors = pcall(require, "src.core.game3.doors") + if okDoors and Doors and Doors.getDoorEntryAt then + local entry = Doors.getDoorEntryAt(curMap, cx, cy) + if entry then + return { + warp = w, + destMap = destMap, + destX = destX, + destY = destY, + x = cx, + y = cy, + doorEntry = entry, + } + end end + return nil end ---- Check if cell (cx, cy) is an indoor exit mat warp +--- Check if cell (cx, cy) is an indoor exit mat warp leading to an outdoor animated door function Collision.isExitWarp(game, cx, cy) local w = Collision.warpAt(cx, cy) if not w then @@ -511,20 +530,22 @@ function Collision.isExitWarp(game, cx, cy) local destMap, destX, destY = resolveDest(game, w) if not destMap then return nil end - local curMap = (game and game.currentMap) or (Collision._mapId) - local curUpper = string.upper(tostring(curMap or "")) - local destUpper = string.upper(tostring(destMap or "")) - - if isBuilding(curUpper) and not isBuilding(destUpper) then - return { - warp = w, - destMap = destMap, - destX = destX, - destY = destY, - x = cx, - y = cy, - } + local okDoors, Doors = pcall(require, "src.core.game3.doors") + if okDoors and Doors and Doors.getDoorEntryAt then + local entry = Doors.getDoorEntryAt(destMap, destX, destY) + if entry then + return { + warp = w, + destMap = destMap, + destX = destX, + destY = destY, + x = cx, + y = cy, + doorEntry = entry, + } + end end + return nil end @@ -603,18 +624,16 @@ function Collision.tryWarpAt(game, cx, cy, facing) local curMap = (game and game.currentMap) or (Collision._mapId) local curUpper = string.upper(tostring(curMap or "")) local destUpper = string.upper(tostring(destMap or "")) - - -- Suppress auto-warp on landing for doors, exit mats & escalators - if isBuilding(curUpper) and not isBuilding(destUpper) then - return false - end - local isEscalator = (curUpper:find("POKECENTER") or curUpper:find("POKEMON_CENTER") or curUpper:find("DEPT_STORE")) - and (destUpper:find("POKECENTER") or destUpper:find("POKEMON_CENTER") or destUpper:find("DEPT_STORE")) - if isEscalator then - return false - end local coll = Collision.cell(cx, cy) - if (coll == 0x71) or (not isBuilding(curUpper) and isBuilding(destUpper)) then + + -- Suppress auto-warp on landing for animated entrance doors, animated exit mats & escalators + if Collision.isDoorWarp and Collision.isDoorWarp(game, cx, cy) then + return false + end + if Collision.isExitWarp and Collision.isExitWarp(game, cx, cy) then + return false + end + if Collision.isEscalatorWarp and Collision.isEscalatorWarp(game, cx, cy, facing) then return false end @@ -632,12 +651,6 @@ function Collision.tryWarpAt(game, cx, cy, facing) local isFallHole = (coll == 0x76) or ((curUpper:find("SEAFOAM") or curUpper:find("MT_MOON") or curUpper:find("VICTORY_ROAD")) and coll == 0x76) - if isEscalator then - local escDir = (destUpper:find("2F") or destUpper:find("3F") or destUpper:find("4F") or destUpper:find("5F") or destUpper:find("6F")) and "up" or "down" - if curUpper:find("2F") and destUpper:find("1F") then escDir = "down" end - return Warp.startEscalator(mod, g, destMap, destX, destY, escDir) - end - if isTeleport then return Warp.startTeleport(mod, g, destMap, destX, destY) end diff --git a/src/core/game3/doors.lua b/src/core/game3/doors.lua index 4fc30f0a..6f6855bd 100644 --- a/src/core/game3/doors.lua +++ b/src/core/game3/doors.lua @@ -46,43 +46,117 @@ local function loadManifest() return Doors._manifest end ---- Get door metadata entry for a map tile at (x, y) if available -function Doors.getDoorEntryAt(mapId, x, y) - local manifest = loadManifest() - if not manifest or not manifest.by_mid then return nil end +Doors._layoutCache = {} - local layout = nil - local Map = package.loaded["src.core.game3.map"] +local function isPairMatch(doorTileset, pair) + if not doorTileset or doorTileset == "primary" then return true end + local p = string.lower(tostring(pair or "")) + if p == "" then return false end + if doorTileset == "pallet" and (p:find("pallet") or p:find("oaks_lab")) then return true end + if doorTileset == "viridian" and p:find("viridian") then return true end + if doorTileset == "pewter" and p:find("pewter_outdoor") then return true end + if doorTileset == "saffron" and p:find("saffron") then return true end + if doorTileset == "cerulean" and p:find("cerulean") then return true end + if doorTileset == "lavender" and p:find("lavender") then return true end + if doorTileset == "vermilion" and p:find("vermilion") then return true end + if doorTileset == "celadon" and p:find("celadon") then return true end + if doorTileset == "fuchsia" and p:find("fuchsia") then return true end + if doorTileset == "cinnabar" and p:find("cinnabar") then return true end + if doorTileset == "sevii_123" and (p:find("sevii_outdoor") or p:find("sevii_123") or p:find("one_island") or p:find("two_island") or p:find("three_island")) then return true end + if doorTileset == "sevii_45" and (p:find("sevii_45") or p:find("four_island") or p:find("five_island") or p:find("rocket_warehouse")) then return true end + if doorTileset == "sevii_67" and (p:find("sevii_67") or p:find("six_island") or p:find("seven_island")) then return true end + if doorTileset == "dept_store" and (p:find("dept_store") or p:find("department_store")) then return true end + if doorTileset == "cable_club" and (p:find("cable_club") or p:find("network") or p:find("pokemon_center")) then return true end + if doorTileset == "silph_co" and (p:find("silph_co") or p:find("rocket_hideout")) then return true end + if doorTileset == "ss_anne" and p:find("ss_anne") then return true end + if doorTileset == "sea_cottage" and p:find("sea_cottage") then return true end + if doorTileset == "trainer_tower" and p:find("trainer_tower") then return true end + return false +end +local function resolveLayout(mapId) + if not mapId then return nil end local function norm(m) return tostring(m or ""):gsub("^FR_", ""):gsub("^MAP_", "") end + local key = norm(mapId) + local Map = package.loaded["src.core.game3.map"] if Map and Map._def and Map._def.midLayout then - if not mapId or norm(Map.current) == norm(mapId) then - layout = Map._def.midLayout + if norm(Map.current) == key or norm(Map._def.id or Map._def.name) == key then + return Map._def.midLayout, Map._def.pair end end - if not layout and Map and Map.neighbors then + if Map and Map.neighbors then for _, n in pairs(Map.neighbors) do if n.def and n.def.midLayout then - if norm(n.map or n.mapId) == norm(mapId) then - layout = n.def.midLayout - break + if norm(n.map or n.mapId) == key then + return n.def.midLayout, n.def.pair end end end end - if not layout and mapId then - local okD, Dataset = pcall(require, "src.core.game3.dataset") - if okD and Dataset and Dataset.map then - local m = Dataset.map(mapId) or Dataset.map("FR_" .. norm(mapId)) - if m and m.midLayout then layout = m.midLayout end + local Runtime = package.loaded["src.core.game3.runtime"] + local g = Runtime and Runtime._game + if g and g.data and g.data.maps then + local m = g.data.maps[mapId] or g.data.maps["FR_" .. key] or g.data.maps[key] + if m and m.midLayout then + return m.midLayout, m.pair end end + if Doors._layoutCache[key] ~= nil then + local cached = Doors._layoutCache[key] + if cached then return cached, cached.pair end + return nil, nil + end + + local okD, Dataset = pcall(require, "src.core.game3.dataset") + if okD and Dataset then + local cache = Dataset.cache and Dataset.cache() + if cache then + local rel1 = "data/generated/gba/native/layouts/" .. mapId .. ".mid" + local rel2 = "data/generated/gba/native/layouts/FR_" .. key .. ".mid" + local rel3 = "data/generated/gba/native/layouts/" .. key .. ".mid" + local blob = cache:read(rel1) or cache:read(rel2) or cache:read(rel3) + if blob then + local pair = nil + local okM, natManifest = pcall(require, "data.generated.gba.native.manifest") + if okM and natManifest and natManifest.layouts then + local info = natManifest.layouts[mapId] or natManifest.layouts["FR_" .. key] or natManifest.layouts[key] + pair = info and info.pair + end + if not pair then + local okV, Versions = pcall(require, "src.import.gba.versions") + if okV and Versions and Versions.MAPS then + local spec = Versions.MAPS[mapId] or Versions.MAPS["FR_" .. key] or Versions.MAPS[key] + pair = spec and spec.pair + end + end + local NativePack = require("src.import.gba.native_pack") + local LayoutNative = require("src.core.game3.layout_native") + local decoded = NativePack.decodeMidLayout(blob) + if decoded then + local layout = LayoutNative.fromDecoded(decoded, mapId, pair) + Doors._layoutCache[key] = layout + return layout, pair + end + end + end + end + + Doors._layoutCache[key] = false + return nil, nil +end + +--- Get door metadata entry for a map tile at (x, y) if available +function Doors.getDoorEntryAt(mapId, x, y) + local manifest = loadManifest() + if not manifest or not manifest.by_mid then return nil end + + local layout, pair = resolveLayout(mapId) local mid = nil if layout and layout.midAt then mid = layout:midAt(x, y) @@ -90,8 +164,11 @@ function Doors.getDoorEntryAt(mapId, x, y) if mid and manifest.by_mid[mid] then local entry = manifest.by_mid[mid] - local doorInfo = manifest.doors and manifest.doors[entry.tile] - return entry, doorInfo + local p = pair or (layout and layout.pair) + if isPairMatch(entry.tileset, p) then + local doorInfo = manifest.doors and manifest.doors[entry.tile] + return entry, doorInfo + end end return nil @@ -104,17 +181,12 @@ function Doors.getSoundForWarp(mapId, x, y, destMap, isDoor) end -- Check ROM metatile manifest first at (mapId, x, y) - local entry, _ = Doors.getDoorEntryAt(mapId, x, y) - if entry then - local snd = (entry.sound == "sliding") and Doors.SOUND_SLIDING or Doors.SOUND_NORMAL - return snd, entry.tile - end - - -- Also check destination map at (x, y) if exiting a building - local destEntry, _ = Doors.getDoorEntryAt(destMap, x, y) - if destEntry then - local snd = (destEntry.sound == "sliding") and Doors.SOUND_SLIDING or Doors.SOUND_NORMAL - return snd, destEntry.tile + if x and y then + local entry, _ = Doors.getDoorEntryAt(mapId, x, y) + if entry then + local snd = (entry.sound == "sliding") and Doors.SOUND_SLIDING or Doors.SOUND_NORMAL + return snd, entry.tile + end end local mapUpper = string.upper(tostring(mapId or "")) @@ -155,9 +227,11 @@ function Doors.getSoundForWarp(mapId, x, y, destMap, isDoor) end local function resolveDoorKind(mapId, x, y, destMap, sound) - local entry, _ = Doors.getDoorEntryAt(mapId, x, y) - if entry then - return entry.tile, entry.size + if x and y then + local entry, _ = Doors.getDoorEntryAt(mapId, x, y) + if entry then + return entry.tile, entry.size + end end if sound == Doors.SOUND_SLIDING then return "SlidingSingle", "1x1" diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 3c0c26d6..dde414d1 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -251,15 +251,72 @@ function Field.interact(game) local DIR_BY_FACING = { down = 1, up = 2, left = 3, right = 4 } local facingDir = DIR_BY_FACING[P.facing] or 1 - -- 1) EventObject (nurse behind counter uses doubled cell) + local FieldMoves = require("src.core.game3.field_moves") + local party = Field._session and Field._session.party + + -- 1) EventObject (nurse behind counter uses doubled cell; Cut tree / Rock / Boulder) local ox, oy = facing_object_cell(fx, fy, P.facing) local eo = Objects.at(ox, oy) - if eo and eo.def and eo.def.scriptKey then - local lid = eo.localId or eo.def.localId or eo.def.index or 0 - Objects.freeze(lid) - Objects.facePlayer(lid, game) - Space.startScript(eo.def.scriptKey, lid, facingDir) - return true + if eo and eo.def then + local gfx = eo.def.graphicsId or eo.def.gfx + if gfx == FieldMoves.GFX_IDS.CUT_TREE then + local ctx = { party = party, store = Space.store, session = Field._session, facingObject = eo } + local res = FieldMoves.tryCutOW(ctx) + if res.ask then + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + Message.show(res.ask, function() + Choice.yesNo(function(yes) + if yes then Field.executeFieldMove(res) else Message.close() end + end) + end) + return true + elseif res.text then + local Message = require("src.ui.game3.message") + Message.show(res.text) + return true + end + elseif gfx == FieldMoves.GFX_IDS.ROCK_SMASH_ROCK then + local ctx = { party = party, store = Space.store, session = Field._session, facingObject = eo } + local res = FieldMoves.tryRockSmashOW(ctx) + if res.ask then + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + Message.show(res.ask, function() + Choice.yesNo(function(yes) + if yes then Field.executeFieldMove(res) else Message.close() end + end) + end) + return true + elseif res.text then + local Message = require("src.ui.game3.message") + Message.show(res.text) + return true + end + elseif gfx == FieldMoves.GFX_IDS.PUSHABLE_BOULDER then + local ctx = { party = party, store = Space.store, session = Field._session, facingObject = eo } + local res = FieldMoves.tryStrengthOW(ctx) + if res.ask then + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + Message.show(res.ask, function() + Choice.yesNo(function(yes) + if yes then Field.executeFieldMove(res) else Message.close() end + end) + end) + return true + elseif res.text then + local Message = require("src.ui.game3.message") + Message.show(res.text) + return true + end + elseif eo.def.scriptKey then + local lid = eo.localId or eo.def.localId or eo.def.index or 0 + Objects.freeze(lid) + Objects.facePlayer(lid, game) + Space.startScript(eo.def.scriptKey, lid, facingDir) + return true + end end -- 2) Extracted bgEvents (signs) — face cell only, not doubled @@ -269,7 +326,23 @@ function Field.interact(game) return true end - -- 3) Metatile-behavior std scripts (PC, …) + -- 3) Water / Surf interact on facing water tile + if not P.surfing and Collision.isWater and Collision.isWater(fx, fy) then + local ctx = { party = party, store = Space.store, session = Field._session, isFacingWater = true } + local res = FieldMoves.trySurfOW(ctx) + if res.ask then + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + Message.show(res.ask, function() + Choice.yesNo(function(yes) + if yes then Field.executeFieldMove(res) else Message.close() end + end) + end) + return true + end + end + + -- 4) Metatile-behavior std scripts (PC, …) local coll = Collision.cell and Collision.cell(fx, fy) local stdKey = CollisionStd.scriptFor(coll) if stdKey then @@ -280,6 +353,112 @@ function Field.interact(game) return false end +function Field.executeFieldMove(payload) + if not payload then return end + local Message = require("src.ui.game3.message") + local Audio = require("src.core.game3.audio") + local FieldEffects = require("src.core.game3.field_effects") + local P = require("src.core.game3.player") + local Objects = require("src.core.game3.objects") + + local act = payload.action + if act == "cut_tree" then + if payload.se then Audio.playSe(payload.se) end + local target = payload.target + if target then + local lid = target.localId or (target.def and (target.def.localId or target.def.index)) + if lid then Objects.removeObject(lid) end + FieldEffects.startCutGrass(target.x or (target.def and target.def.x) or P.cellX, + target.y or (target.def and target.def.y) or P.cellY) + end + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "cut_grass" then + if payload.se then Audio.playSe(payload.se) end + local Map = require("src.core.game3.map") + local def = Map.currentDef() + local layout = def and def.midLayout + if layout and layout.midAt and layout.setMidAt then + local Collision = require("src.core.game3.collision") + local FieldMoves = require("src.core.game3.field_moves") + FieldMoves.mowGrass3x3(P.cellX, P.cellY, function(x, y) return layout:midAt(x, y) end, + function(x, y, mid) layout:setMidAt(x, y, mid) end, + function(x, y) return Collision.isGrass(x, y) end) + local okFv, FieldView = pcall(require, "src.core.game3.field_view") + if okFv and FieldView then FieldView._nativeDirty = true end + end + FieldEffects.startCutGrass(P.cellX, P.cellY) + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "rock_smash" then + if payload.se then Audio.playSe(payload.se) end + local target = payload.target + if target then + local lid = target.localId or (target.def and (target.def.localId or target.def.index)) + if lid then Objects.removeObject(lid) end + FieldEffects.startRockSmash(target.x or (target.def and target.def.x) or P.cellX, + target.y or (target.def and target.def.y) or P.cellY) + end + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "strength" then + if payload.flag then + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + if Space and Space.store then + Flags.setFlag(Space.store, nil, payload.flag, true) + end + if Field._session and Field._session.flags then + Field._session.flags[payload.flag] = true + end + end + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "surf" then + P.startSurfing(Field._game) + P.forceStep(P.facing) + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "flash" then + if payload.se then Audio.playSe(payload.se) end + if payload.flag then + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + if Space and Space.store then + Flags.setFlag(Space.store, nil, payload.flag, true) + end + end + FieldEffects.startFlash() + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "teleport" or act == "dig" then + if payload.se then Audio.playSe(payload.se) end + FieldEffects.startWarpSpin(act, function() + Field.respawnAtHeal() + end) + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + elseif act == "sweet_scent" then + if payload.se then Audio.playSe(payload.se) end + FieldEffects.startSweetScent(function() + local okE, Encounters = pcall(require, "src.core.game3.encounters") + if okE and Encounters and Encounters.tryBattle then + Encounters.tryBattle(Field._game, true) + end + end) + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end +end + --- White-out / heal respawn via game3 map loader (H7). function Field.respawnAtHeal() local session = Field._session diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 210ee66d..0f93b629 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -1,23 +1,21 @@ --- FRLG tall-grass field effect (pret FldEff_TallGrass / UpdateGrassFieldEffectSubpriority). --- Layering: full frame behind the avatar, bottom strip in front (covers feet). --- Frame 1 is a solid green pad in ROM art — must stay behind the body. +-- FRLG field effects engine (pret fldeff_*.c). +-- Handles pure ROM-extracted field effect sprites and animations: +-- Tall grass, Cut grass leaves, Rock smash rubble, Surf blob, Fly bird, Ripples, +-- Flash screen flash, Dig / Teleport warp spin, Sweet scent aroma. local Extract = require("src.import.gba.extract_island1") local FieldEffects = {} FieldEffects._cache = nil -FieldEffects._image = nil -FieldEffects._quads = nil -- [frame] = full 16×16 -FieldEffects._quadsFront = nil -- [frame] = bottom FEET_H strip -FieldEffects._fx = nil +FieldEffects._sheets = {} -- [name] = { image = ..., quads = ..., fw = ..., fh = ..., frames = ... } +FieldEffects._fx = nil -- tall grass +FieldEffects._anims = {} -- transient active field animations +FieldEffects._surfClock = 0 FieldEffects._logged = false local CELL = 16 -local FRAME_W, FRAME_H = 16, 16 -local FRAME_COUNT = 5 -local FEET_H = 8 -- pret: lower half covers feet; upper stays behind body --- pret sAnim_TallGrass: frames 1,2,3,4,0 × 10 vblanks each. +local FEET_H = 8 local RUSTLE = { 1, 2, 3, 4, 0 } local FRAME_DUR = 10 @@ -45,7 +43,6 @@ end local function try_load_png(path) if not (love and love.graphics and love.graphics.newImage) then return nil end - -- Indexed PNGs: love honors transparency; prefer ROM rgba extract. local ok, img = pcall(love.graphics.newImage, path) if ok and img then if img.setFilter then img:setFilter("nearest", "nearest") end @@ -54,72 +51,66 @@ local function try_load_png(path) return nil end +local function load_sheet(name, fw, fh, frames) + if FieldEffects._sheets[name] then return FieldEffects._sheets[name] end + local totalH = fh * frames + local root = cache_root() .. "/field_effects/" + local img = try_load_rgba(FieldEffects._cache, root .. name .. ".rgba", fw, totalH) + or try_load_rgba(FieldEffects._cache, "field_effects/" .. name .. ".rgba", fw, totalH) + or try_load_png(root .. name .. ".png") + if not img then return nil end + + local quads = {} + local quadsFront = {} + local iw, ih = img:getDimensions() + for i = 0, frames - 1 do + local y = i * fh + if y + fh <= ih then + quads[i] = love.graphics.newQuad(0, y, fw, fh, iw, ih) + if fh >= FEET_H then + quadsFront[i] = love.graphics.newQuad(0, y + (fh - FEET_H), fw, FEET_H, iw, ih) + end + end + end + + local sheet = { + image = img, + quads = quads, + quadsFront = quadsFront, + fw = fw, + fh = fh, + frames = frames, + } + FieldEffects._sheets[name] = sheet + return sheet +end + function FieldEffects.install(cache) FieldEffects._cache = cache - FieldEffects._image = nil - FieldEffects._quads = nil - FieldEffects._quadsFront = nil + FieldEffects._sheets = {} FieldEffects._fx = nil + FieldEffects._anims = {} + FieldEffects._surfClock = 0 FieldEffects._logged = false local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.install then Heal.install(cache) end end function FieldEffects.invalidate() - FieldEffects._image = nil - FieldEffects._quads = nil - FieldEffects._quadsFront = nil + FieldEffects._sheets = {} FieldEffects._fx = nil + FieldEffects._anims = {} local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.invalidate then Heal.invalidate() end end -local function ensure_sheet() - if FieldEffects._image and FieldEffects._quads and FieldEffects._quadsFront then - return true - end - local img = try_load_rgba( - FieldEffects._cache, cache_root() .. "/field_effects/tall_grass.rgba", 16, 80) - or try_load_rgba(FieldEffects._cache, "field_effects/tall_grass.rgba", 16, 80) - if not img then - img = try_load_png(cache_root() .. "/field_effects/tall_grass.png") - end - if not img then - log("tall_grass sheet missing — re-run gba extract") - return false - end - local quads, quadsFront = {}, {} - local iw, ih = img:getDimensions() - for i = 0, FRAME_COUNT - 1 do - local y = i * FRAME_H - if y + FRAME_H <= ih then - quads[i] = love.graphics.newQuad(0, y, FRAME_W, FRAME_H, iw, ih) - quadsFront[i] = love.graphics.newQuad( - 0, y + (FRAME_H - FEET_H), FRAME_W, FEET_H, iw, ih) - end - end - FieldEffects._image = img - FieldEffects._quads = quads - FieldEffects._quadsFront = quadsFront - log("tall_grass ready (behind+feet OAM split)") - return true -end - -local function current_frame() - local fx = FieldEffects._fx - if not fx then return nil end - return RUSTLE[fx.step + 1] or 0 -end - ---- Start / refresh tall grass rustle at map cell (cx, cy). --- seekEnd: pret spawn-on-grass SeekSpriteAnim near anim end (idle feet cover). +-- ---------------------------------------------------------------- Tall Grass function FieldEffects.tallGrassAt(cx, cy, seekEnd) - if not ensure_sheet() then return end + local sheet = load_sheet("tall_grass", 16, 16, 5) + if not sheet then return end cx, cy = tonumber(cx) or 0, tonumber(cy) or 0 local fx = FieldEffects._fx - if fx and fx.cx == cx and fx.cy == cy and not fx.leaving then - return - end + if fx and fx.cx == cx and fx.cy == cy and not fx.leaving then return end FieldEffects._fx = { cx = cx, cy = cy, @@ -140,7 +131,118 @@ function FieldEffects.leaveTallGrass() fx.leaving = true end +-- ---------------------------------------------------------------- Transient Animations + +--- Cut grass leaves scattering animation +function FieldEffects.startCutGrass(cx, cy, onDone) + load_sheet("cut_grass", 16, 16, 4) + local anim = { + kind = "cut_grass", + cx = cx, + cy = cy, + frame = 0, + timer = 0, + frameDur = 6, + maxFrames = 4, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +--- Rock smash rubble exploding animation +function FieldEffects.startRockSmash(cx, cy, onDone) + load_sheet("rock_smash", 16, 16, 4) + local anim = { + kind = "rock_smash", + cx = cx, + cy = cy, + frame = 0, + timer = 0, + frameDur = 6, + maxFrames = 4, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +--- Screen flash animation (Flash HM) +function FieldEffects.startFlash(onDone) + local anim = { + kind = "flash", + alpha = 1.0, + timer = 0, + maxDur = 30, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +--- Fly Bird takeoff and landing animations +function FieldEffects.startFlyTakeoff(onMidWarp, onDone) + load_sheet("fly_bird", 32, 32, 4) + local P = package.loaded["src.core.game3.player"] + local px = P and P.px or 0 + local py = P and P.py or 0 + local anim = { + kind = "fly_takeoff", + px = px - 8, + py = py - 40, + targetPy = py - 8, + state = "descend", + timer = 0, + frame = 0, + onMidWarp = onMidWarp, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +function FieldEffects.startFlyLanding(onDone) + load_sheet("fly_bird", 32, 32, 4) + local P = package.loaded["src.core.game3.player"] + local px = P and P.px or 0 + local py = P and P.py or 0 + local anim = { + kind = "fly_landing", + px = px - 8, + py = py - 60, + targetPy = py - 8, + state = "descend", + timer = 0, + frame = 0, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +--- Dig / Teleport warp spin +function FieldEffects.startWarpSpin(kind, onDone) + local P = package.loaded["src.core.game3.player"] + local anim = { + kind = "warp_spin", + warpKind = kind or "teleport", + timer = 0, + maxDur = 40, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +--- Sweet scent aroma waves +function FieldEffects.startSweetScent(onDone) + local anim = { + kind = "sweet_scent", + timer = 0, + maxDur = 50, + radius = 0, + onDone = onDone, + } + table.insert(FieldEffects._anims, anim) +end + +-- ---------------------------------------------------------------- Step & Update function FieldEffects.step() + -- Tall grass update local fx = FieldEffects._fx if fx and not fx.done then fx.timer = fx.timer + 1 @@ -152,93 +254,211 @@ function FieldEffects.step() fx.done = true FieldEffects._fx = nil else - fx.step = #RUSTLE - 1 -- hold frame 0 (feet cover only) + fx.step = #RUSTLE - 1 end end end end + + -- Surfing blob clock + FieldEffects._surfClock = (FieldEffects._surfClock + 1) % 48 + + -- Update active transient animations + local active = {} + for _, anim in ipairs(FieldEffects._anims) do + anim.timer = anim.timer + 1 + local finished = false + + if anim.kind == "cut_grass" or anim.kind == "rock_smash" then + if anim.timer >= anim.frameDur then + anim.timer = 0 + anim.frame = anim.frame + 1 + if anim.frame >= anim.maxFrames then + finished = true + end + end + elseif anim.kind == "flash" then + anim.alpha = math.max(0, 1.0 - (anim.timer / anim.maxDur)) + if anim.timer >= anim.maxDur then + finished = true + end + elseif anim.kind == "fly_takeoff" then + anim.frame = math.floor(anim.timer / 4) % 4 + if anim.state == "descend" then + anim.py = anim.py + 2 + if anim.py >= anim.targetPy then + anim.py = anim.targetPy + anim.state = "ascend" + end + elseif anim.state == "ascend" then + anim.py = anim.py - 3 + if anim.py <= -50 then + finished = true + if anim.onMidWarp then anim.onMidWarp() end + end + end + elseif anim.kind == "fly_landing" then + anim.frame = math.floor(anim.timer / 4) % 4 + if anim.state == "descend" then + anim.py = anim.py + 2 + if anim.py >= anim.targetPy then + anim.py = anim.targetPy + anim.state = "leave" + end + elseif anim.state == "leave" then + anim.py = anim.py - 3 + if anim.py <= -50 then + finished = true + end + end + elseif anim.kind == "warp_spin" then + local P = package.loaded["src.core.game3.player"] + local facings = { "down", "left", "up", "right" } + if P then + P.facing = facings[(math.floor(anim.timer / 3) % 4) + 1] + end + if anim.timer >= anim.maxDur then + finished = true + end + elseif anim.kind == "sweet_scent" then + anim.radius = (anim.timer / anim.maxDur) * 120 + if anim.timer >= anim.maxDur then + finished = true + end + end + + if finished then + if anim.onDone then anim.onDone() end + else + table.insert(active, anim) + end + end + FieldEffects._anims = active + local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.step then Heal.step() end end ---- pret dofieldeffect / waitfieldeffect for FLDEFF_POKECENTER_HEAL (25). -function FieldEffects.doFieldEffect(id) - id = tonumber(id) or 0 - local Heal = require("src.core.game3.pokecenter_heal") - if id == Heal.FLDEFF then - return Heal.start() - end - return false -end +-- ---------------------------------------------------------------- Drawing -function FieldEffects.waitFieldEffect(id, done) - id = tonumber(id) or 0 - local Heal = require("src.core.game3.pokecenter_heal") - if id == Heal.FLDEFF then - Heal.wait(done) - return - end - if done then done() end -end - -function FieldEffects.isFieldEffectActive(id) - id = tonumber(id) or 0 - local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") - if ok and Heal and id == Heal.FLDEFF then - return Heal.isActive() - end - return false -end - -local function screen_xy(camX, camY) - local fx = FieldEffects._fx - if not fx then return nil end - return fx.cx * CELL - (camX or 0), fx.cy * CELL - (camY or 0) -end - ---- Pret: grass under avatar body (lower OAM priority / drawn first). +--- Draw behind player (Surf blob, tall grass bottom, etc.) function FieldEffects.drawBehind(camX, camY) - local fx = FieldEffects._fx - if not fx or not FieldEffects._image then return end - local frameIdx = current_frame() - local q = FieldEffects._quads and FieldEffects._quads[frameIdx] - if not q then return end - local sx, sy = screen_xy(camX, camY) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(FieldEffects._image, q, sx, sy) -end + camX, camY = camX or 0, camY or 0 ---- Pret: grass over feet (subpriority bumped above avatar). --- playerPy: world Y of avatar cell origin (Player.py). Front strip is only --- drawn when the feet sit in this grass cell — otherwise walking *up* into --- the tile paints the destination's bottom strip over the hat/head. -function FieldEffects.drawFront(camX, camY, playerPy) - local fx = FieldEffects._fx - if not fx or not FieldEffects._image then return end - if playerPy ~= nil then - local feetY = playerPy + CELL - local grassTop = fx.cy * CELL - local grassBot = grassTop + CELL - -- Feet must reach the lower half of the grass cell (cover zone). - if feetY < grassTop + FEET_H or feetY > grassBot + 2 then - return + -- 1) Surfing water mount (pret FLDEFF_SURF_BLOB) + local P = package.loaded["src.core.game3.player"] + if P and P.surfing then + local surfSheet = load_sheet("surf_blob", 32, 32, 6) + if surfSheet then + local frameIdx = math.floor(FieldEffects._surfClock / 8) % 6 + local q = surfSheet.quads[frameIdx] + if q then + local sx = P.px - camX - 8 + local sy = P.py - camY + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(surfSheet.image, q, sx, sy) + end + end + end + + -- 2) Tall grass base pad + local fx = FieldEffects._fx + if fx then + local sheet = load_sheet("tall_grass", 16, 16, 5) + if sheet then + local frameIdx = RUSTLE[fx.step + 1] or 0 + local q = sheet.quads[frameIdx] + if q then + local sx = fx.cx * CELL - camX + local sy = fx.cy * CELL - camY + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, sx, sy) + end + end + 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 + + -- 1) Tall grass feet cover + 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 + 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 + end + end + end + + -- 2) Transient particle animations + for _, anim in ipairs(FieldEffects._anims) do + if anim.kind == "cut_grass" then + local sheet = load_sheet("cut_grass", 16, 16, 4) + if sheet and sheet.quads[anim.frame] then + local sx = anim.cx * CELL - camX + local sy = anim.cy * CELL - camY + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, sheet.quads[anim.frame], sx, sy) + end + elseif anim.kind == "rock_smash" then + local sheet = load_sheet("rock_smash", 16, 16, 4) + if sheet and sheet.quads[anim.frame] then + local sx = anim.cx * CELL - camX + local sy = anim.cy * CELL - camY + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, sheet.quads[anim.frame], sx, sy) + end + elseif anim.kind == "fly_takeoff" or anim.kind == "fly_landing" then + local sheet = load_sheet("fly_bird", 32, 32, 4) + if sheet and sheet.quads[anim.frame] then + local sx = anim.px - camX + local sy = anim.py - camY + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, sheet.quads[anim.frame], sx, sy) + end end end - local frameIdx = current_frame() - local q = FieldEffects._quadsFront and FieldEffects._quadsFront[frameIdx] - if not q then return end - local sx, sy = screen_xy(camX, camY) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(FieldEffects._image, q, sx, sy + (FRAME_H - FEET_H)) end ---- Legacy single-pass (front only) — prefer drawBehind + drawFront. function FieldEffects.draw(camX, camY, playerPy) FieldEffects.drawFront(camX, camY, playerPy) end ---- Screen-space Pokemon Center heal machine (after actors). +--- Screen-space overlay (Flash screen glow, Sweet scent aroma, Pokemon Center heal) 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 + love.graphics.setColor(1, 1, 1, anim.alpha) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + elseif anim.kind == "sweet_scent" then + love.graphics.setColor(1, 0.7, 0.9, 0.6 * (1.0 - (anim.timer / anim.maxDur))) + love.graphics.circle("line", 120, 80, anim.radius) + love.graphics.circle("line", 120, 80, math.max(0, anim.radius - 20)) + love.graphics.setColor(1, 1, 1, 1) + 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 end diff --git a/src/core/game3/field_moves.lua b/src/core/game3/field_moves.lua index a3ef4bc1..aa50cba8 100644 --- a/src/core/game3/field_moves.lua +++ b/src/core/game3/field_moves.lua @@ -717,7 +717,7 @@ end -- `getMetatileFn(x, y)`: returns numeric metatileId -- `setMetatileFn(x, y, newMetatileId)`: applies new metatileId -- Returns count of cut tiles -function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn) +function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn, isGrassFn) if not getMetatileFn or not setMetatileFn then return 0 end local count = 0 @@ -726,10 +726,16 @@ function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn) local x = cx + dx local y = cy + dy local mid = getMetatileFn(x, y) - if mid and FieldMoves.CUT_GRASS_METATILES[mid] then + if mid then local newMid = FieldMoves.CUT_GRASS_METATILES[mid] - setMetatileFn(x, y, newMid) - count = count + 1 + if not newMid and isGrassFn and isGrassFn(x, y) then + -- Default flat-ground replacement in general tileset + newMid = 0x001 + end + if newMid then + setMetatileFn(x, y, newMid) + count = count + 1 + end end end end diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index d7f450d3..3c5aead8 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -270,6 +270,16 @@ function Map.load(mod, game, mapId, opts) if Space and Space.activate then Space.activate(mod or Runtime._mod, mapId, game, world) end + + -- Strength flag is map-instance local in FRLG (clears on map change / warp). + if Space and Space.store then + local Flags = require("src.core.game3.scripting.flags") + Flags.setFlag(Space.store, nil, 0x804, false) + end + if session and session.flags then + session.flags[0x804] = nil + end + if def then Objects.loadMap(game, mapId, def) end if Space and Space.runEnterScripts then Space.runEnterScripts(mod or Runtime._mod, mapId, game, world) diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index 8d41593a..2884acd8 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -668,6 +668,14 @@ function Objects.removeObject(localId) localId = tonumber(localId) or 0 local eo = Objects._byId[localId] if not eo then return false end + local flag = eo.def and (eo.def.flag or eo.def.flagId) + if flag and flag ~= 0 and flag ~= 0xFFFF and flag ~= 65535 then + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store then + local Flags = require("src.core.game3.scripting.flags") + Flags.setFlag(Space.store, nil, flag, true) + end + end eo.hidden = true eo.visible = false if eo.def then eo.def.hidden = true end diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index 25ce3dee..85589536 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -290,9 +290,36 @@ function Player.tryMove(dir, game, run) fromX = Player.cellX, fromY = Player.cellY, dir = dir, + surfing = Player.surfing, }) if not ok then + if why == "entity" then + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + local FieldMoves = require("src.core.game3.field_moves") + local isStrengthActive = Space and Space.store and Flags.getFlag(Space.store, nil, FieldMoves.SYS_FLAGS.USE_STRENGTH) + if isStrengthActive then + local Objects = require("src.core.game3.objects") + local obj = Objects.at(tx, ty) + if obj and (obj.def and (obj.def.graphicsId == FieldMoves.GFX_IDS.PUSHABLE_BOULDER or obj.def.gfx == FieldMoves.GFX_IDS.PUSHABLE_BOULDER)) then + local canPush, destBx, destBy = FieldMoves.canPushBoulder(obj, dir, function(bx, by) + return Collision.canEnter(game, bx, by, { fromX = tx, fromY = ty, dir = dir }) + end) + if canPush then + obj.x = destBx + obj.y = destBy + if obj.def then obj.def.x = destBx; obj.def.y = destBy end + beginStep(tx, ty, false, false) + return "step" + end + end + end + end + if (why == "bounds" or why == "solid" or why == "tile") and Collision.tryWarpAt + and Collision.tryWarpAt(game, Player.cellX, Player.cellY, dir) then + return "warp" + end -- Outdoor map connection (Pallet north → Route 1, etc.). if why == "bounds" and Collision.tryConnection and Collision.tryConnection(game, Player.cellX, Player.cellY, dir, run) then @@ -329,6 +356,12 @@ function Player.scriptFace(dir) if DELTA[dir] then Player.facing = dir end end +function Player.startSurfing(game) + Player.surfing = true + Player.biking = false -- Bike override: clear bike state when using Surf + Player.running = false +end + local function finishStep(game) Player.cellX = Player.targetX Player.cellY = Player.targetY @@ -342,6 +375,14 @@ local function finishStep(game) Player.spriteYOffset = 0 Player.syncSavePosition(game) + -- Surf dismount check: stepped onto land from water + if Player.surfing then + local onWater = Collision.isWater and Collision.isWater(Player.cellX, Player.cellY) + if not onWater then + Player.surfing = false + end + end + local session = package.loaded["src.core.game3.runtime"] session = session and session.getSession and session.getSession() if session then diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index ddb0eccb..3ca7e9c5 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -31,6 +31,9 @@ function Schema.newGame(opts) end local session = { schemaVersion = Schema.VERSION, + engine = "game3", + version = opts.version or "firered", + generation = 3, party = {}, bag = Bag.new(), dex = { seen = {}, owned = {}, national = false }, diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index 427699f5..3fc45e6b 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -307,7 +307,11 @@ function Adapters.host(mod, game, world) w:showText(text, finish) return end - HouseNpcs.pushText(g, text, finish) + if HouseNpcs and HouseNpcs.pushText then + HouseNpcs.pushText(g, text, finish) + else + finish() + end end, -- Hold the box open for yesnobox / MSGBOX_YESNO. openMessageStay = function(text, done) @@ -336,7 +340,11 @@ function Adapters.host(mod, game, world) return end if w then w.lastText = text end - HouseNpcs.pushText(g, text, finish) + if HouseNpcs and HouseNpcs.pushText then + HouseNpcs.pushText(g, text, finish) + else + finish() + end end, closeMessage = function() boxOpen = false @@ -364,6 +372,13 @@ function Adapters.host(mod, game, world) if Runtime and Runtime.isActive and Runtime.isActive() then local Hud = require("src.ui.game3.hud") Hud.armWaitButton(function() + -- Close the message box after button press (pret WaitForFieldInput). + -- Stay-mode messages (field item pickups, signs) remain open until + -- explicitly dismissed; without this they persist after the script ends. + local Message = package.loaded["src.ui.game3.message"] + if Message and Message.isOpen and Message.isOpen() then + Message.close() + end if cb then cb() end tick_vm() end) @@ -791,7 +806,9 @@ function Adapters.host(mod, game, world) local ItemsData = require("src.core.game3.items_data") local Game3Bag = require("src.core.game3.bag") local Runtime = package.loaded["src.core.game3.runtime"] - local session = Runtime and Runtime.getSession and Runtime.getSession() + local session = (Runtime and Runtime.getSession and Runtime.getSession()) + or (useGame3Session and useGame3Session()) + or (package.loaded["src.core.game3.field"] and package.loaded["src.core.game3.field"]._session) qty = math.max(1, tonumber(qty) or 1) local num = ItemsData.toNumericId(itemId) or tonumber(itemId) diff --git a/src/core/game3/scripting/flags.lua b/src/core/game3/scripting/flags.lua index f204d349..148116fe 100644 --- a/src/core/game3/scripting/flags.lua +++ b/src/core/game3/scripting/flags.lua @@ -91,9 +91,15 @@ for _, b in ipairs(Flags.BADGES) do BADGE_LOOKUP[b.num] = b BADGE_LOOKUP[b.name] = b BADGE_LOOKUP[b.name:lower()] = b + BADGE_LOOKUP[b.name .. "BADGE"] = b + BADGE_LOOKUP[(b.name .. "BADGE"):lower()] = b + BADGE_LOOKUP[b.name .. "_BADGE"] = b + BADGE_LOOKUP[(b.name .. "_BADGE"):lower()] = b BADGE_LOOKUP[b.fieldMove] = b BADGE_LOOKUP[b.fieldMove:lower()] = b BADGE_LOOKUP[b.flag] = b + BADGE_LOOKUP[tostring(b.flag)] = b + BADGE_LOOKUP[string.format("FLAG_BADGE0%d_GET", b.num)] = b end function Flags.badgeInfo(badgeKey) @@ -226,13 +232,35 @@ end function Flags.getFlag(store, ctx, id) id = tonumber(id) or (type(id) == "string" and Flags.IDS[id]) or 0 if not store or not store.flags then return false end - return store.flags[id] == true + if store.flags[id] == true or store.flags[tostring(id)] == true then + return true + end + if type(id) == "number" and id > 0 then + local hexKey = string.format("0x%X", id) + if store.flags[hexKey] == true then return true end + local name = Flags.NAMES[id] + if name and store.flags[name] == true then return true end + end + return false end function Flags.setFlag(store, ctx, id, on) id = tonumber(id) or (type(id) == "string" and Flags.IDS[id]) or 0 if not store or not store.flags then return end - if on then store.flags[id] = true else store.flags[id] = nil end + local strId = tostring(id) + if on then + store.flags[id] = true + store.flags[strId] = true + else + store.flags[id] = nil + store.flags[strId] = nil + if type(id) == "number" and id > 0 then + local hexKey = string.format("0x%X", id) + store.flags[hexKey] = nil + local name = Flags.NAMES[id] + if name then store.flags[name] = nil end + end + end end function Flags.getVar(store, ctx, id) @@ -264,7 +292,7 @@ function Flags.onMapLoad(store) -- pret: Temp flags 0x01..0x1F are cleared on map load if store and store.flags then for fid = 0x01, 0x1F do - store.flags[fid] = nil + Flags.setFlag(store, nil, fid, false) end end end diff --git a/src/core/game3/scripting/stdscripts.lua b/src/core/game3/scripting/stdscripts.lua index 545918a4..997940cf 100644 --- a/src/core/game3/scripting/stdscripts.lua +++ b/src/core/game3/scripting/stdscripts.lua @@ -198,6 +198,7 @@ Std.SCRIPTS = { { op = "return" }, }, EventScript_PickUpItem = { + { op = "removeobject", [1] = 0x800F }, { op = "additem", [1] = 0x8000, [2] = 0x8001 }, { op = "loadword", dest = 0, value = "Text_FoundOneItem" }, { op = "message", ptr = 0 }, diff --git a/src/core/game3/scripting/vm.lua b/src/core/game3/scripting/vm.lua index 5a8006f1..eb3fadcf 100644 --- a/src/core/game3/scripting/vm.lua +++ b/src/core/game3/scripting/vm.lua @@ -17,10 +17,21 @@ function Vm.new(opts) self.text = opts.text or {} self.movements = opts.movements or {} self.adapters = opts.adapters or Adapters.stub(opts) - self.stdscripts = opts.stdscripts - if self.stdscripts then - for k, rows in pairs(self.stdscripts) do - self.scripts[k] = rows + local std = opts.stdscripts or require("src.core.game3.scripting.stdscripts") + self.stdscripts = std + local stdScripts = (std and std.SCRIPTS) or std + if type(stdScripts) == "table" then + for k, rows in pairs(stdScripts) do + if not self.scripts[k] then + self.scripts[k] = rows + end + end + end + if std and type(std.TEXT) == "table" then + for k, v in pairs(std.TEXT) do + if not self.text[k] then + self.text[k] = v + end end end return self diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 155608bf..04056310 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -215,6 +215,12 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/trainers/back_1.rgba", "data/generated/gba/trainer_card/manifest.lua", "data/generated/gba/trainer_card/bg.rgba", + "data/generated/gba/field_effects/tall_grass.rgba", + "data/generated/gba/field_effects/cut_grass.rgba", + "data/generated/gba/field_effects/rock_smash.rgba", + "data/generated/gba/field_effects/surf_blob.rgba", + "data/generated/gba/field_effects/fly_bird.rgba", + "data/generated/gba/field_effects/ripple.rgba", }, } CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = diff --git a/src/import/gba/door_anim_extract.lua b/src/import/gba/door_anim_extract.lua index 17386c7d..351a4ff5 100644 --- a/src/import/gba/door_anim_extract.lua +++ b/src/import/gba/door_anim_extract.lua @@ -211,13 +211,50 @@ function DoorAnimExtract.run(rom, cache, opts) frames = 3, } +local DOOR_TILESETS = { + [0] = "primary", -- General + [1] = "primary", -- SlidingSingle + [2] = "primary", -- SlidingDouble + [3] = "pallet", -- Pallet + [4] = "pallet", -- OaksLab + [5] = "viridian", -- Viridian + [6] = "pewter", -- Pewter + [7] = "saffron", -- Saffron + [8] = "saffron", -- SilphCo + [9] = "cerulean", -- Cerulean + [10] = "lavender", -- Lavender + [11] = "vermilion", -- Vermilion + [12] = "vermilion", -- PokemonFanClub + [13] = "celadon", -- DeptStore + [14] = "fuchsia", -- Fuchsia + [15] = "fuchsia", -- SafariZone + [16] = "cinnabar", -- CinnabarLab + [17] = "sevii_123", -- Sevii123 + [18] = "sevii_123", -- JoyfulGameCorner + [19] = "sevii_123", -- OneIslandPokeCenter + [20] = "sevii_45", -- Sevii45 + [21] = "sevii_45", -- FourIslandDayCare + [22] = "sevii_45", -- RocketWarehouse + [23] = "sevii_67", -- Sevii67 + [24] = "dept_store", -- DeptStoreElevator + [25] = "cable_club", -- CableClub + [26] = "silph_co", -- HideoutElevator + [27] = "ss_anne", -- SSAnne + [28] = "silph_co", -- SilphCoElevator + [29] = "sea_cottage", -- Teleporter + [30] = "trainer_tower", -- TrainerTowerLobbyElevator + [31] = "trainer_tower", -- TrainerTowerRoofElevator +} + local sound = SOUND_FOR_MID[mid] or "normal" local size = is_large and "1x2" or "1x1" + local tset = DOOR_TILESETS[i] or "primary" manifest_by_mid[mid] = { - mid = mid, - tile = name, - sound = sound, - size = size, + mid = mid, + tile = name, + sound = sound, + size = size, + tileset = tset, } ::continue:: @@ -236,8 +273,8 @@ function DoorAnimExtract.run(rom, cache, opts) mlines[#mlines + 1] = " by_mid = {" for mid, v in pairs(manifest_by_mid) do mlines[#mlines + 1] = string.format( - " [%d] = { mid = %d, tile = %q, sound = %q, size = %q },", - mid, mid, v.tile, v.sound, v.size) + " [%d] = { mid = %d, tile = %q, sound = %q, size = %q, tileset = %q },", + mid, mid, v.tile, v.sound, v.size, v.tileset or "primary") end mlines[#mlines + 1] = " }" mlines[#mlines + 1] = "}" @@ -255,29 +292,29 @@ end -- no ROM handle is available. function DoorAnimExtract._writeStub(cache, root) local STUB_ENTRIES = { - {0x03D,"General","normal","1x1"},{0x062,"SlidingSingle","sliding","1x1"}, - {0x15B,"SlidingDouble","sliding","1x1"},{0x2A3,"Pallet","normal","1x1"}, - {0x2AC,"OaksLab","normal","1x1"},{0x299,"Viridian","normal","1x1"}, - {0x2CE,"Pewter","normal","1x1"},{0x284,"Saffron","normal","1x1"}, - {0x2BC,"SilphCo","sliding","1x1"},{0x298,"Cerulean","normal","1x1"}, - {0x2A2,"Lavender","normal","1x1"},{0x29E,"Vermilion","normal","1x1"}, - {0x2E1,"PokemonFanClub","normal","1x1"},{0x294,"DeptStore","sliding","1x1"}, - {0x2BF,"Fuchsia","normal","1x1"},{0x2D2,"SafariZone","sliding","1x1"}, - {0x2AD,"CinnabarLab","normal","1x1"},{0x297,"Sevii123","normal","1x1"}, - {0x29B,"JoyfulGameCorner","sliding","1x1"},{0x2EB,"OneIslandPokeCenter","normal","1x1"}, - {0x29A,"Sevii45","normal","1x1"},{0x2B9,"FourIslandDayCare","normal","1x1"}, - {0x2AF,"RocketWarehouse","normal","1x1"},{0x30C,"Sevii67","normal","1x1"}, - {0x28D,"DeptStoreElevator","sliding","1x2"},{0x2DE,"CableClub","sliding","1x2"}, - {0x2AB,"HideoutElevator","sliding","1x2"},{0x281,"SSAnne","normal","1x2"}, - {0x2E2,"SilphCoElevator","sliding","1x2"},{0x296,"Teleporter","sliding","1x2"}, - {0x2C3,"TrainerTowerLobbyElevator","sliding","1x2"}, - {0x356,"TrainerTowerRoofElevator","sliding","1x2"}, + {0x03D,"General","normal","1x1","primary"},{0x062,"SlidingSingle","sliding","1x1","primary"}, + {0x15B,"SlidingDouble","sliding","1x1","primary"},{0x2A3,"Pallet","normal","1x1","pallet"}, + {0x2AC,"OaksLab","normal","1x1","pallet"},{0x299,"Viridian","normal","1x1","viridian"}, + {0x2CE,"Pewter","normal","1x1","pewter"},{0x284,"Saffron","normal","1x1","saffron"}, + {0x2BC,"SilphCo","sliding","1x1","saffron"},{0x298,"Cerulean","normal","1x1","cerulean"}, + {0x2A2,"Lavender","normal","1x1","lavender"},{0x29E,"Vermilion","normal","1x1","vermilion"}, + {0x2E1,"PokemonFanClub","normal","1x1","vermilion"},{0x294,"DeptStore","sliding","1x1","celadon"}, + {0x2BF,"Fuchsia","normal","1x1","fuchsia"},{0x2D2,"SafariZone","sliding","1x1","fuchsia"}, + {0x2AD,"CinnabarLab","normal","1x1","cinnabar"},{0x297,"Sevii123","normal","1x1","sevii_123"}, + {0x29B,"JoyfulGameCorner","sliding","1x1","sevii_123"},{0x2EB,"OneIslandPokeCenter","normal","1x1","sevii_123"}, + {0x29A,"Sevii45","normal","1x1","sevii_45"},{0x2B9,"FourIslandDayCare","normal","1x1","sevii_45"}, + {0x2AF,"RocketWarehouse","normal","1x1","sevii_45"},{0x30C,"Sevii67","normal","1x1","sevii_67"}, + {0x28D,"DeptStoreElevator","sliding","1x2","dept_store"},{0x2DE,"CableClub","sliding","1x2","cable_club"}, + {0x2AB,"HideoutElevator","sliding","1x2","silph_co"},{0x281,"SSAnne","normal","1x2","ss_anne"}, + {0x2E2,"SilphCoElevator","sliding","1x2","silph_co"},{0x296,"Teleporter","sliding","1x2","sea_cottage"}, + {0x2C3,"TrainerTowerLobbyElevator","sliding","1x2","trainer_tower"}, + {0x356,"TrainerTowerRoofElevator","sliding","1x2","trainer_tower"}, } local lines = {"return {", " doors = {},", " by_mid = {"} for _, e in ipairs(STUB_ENTRIES) do lines[#lines + 1] = string.format( - " [%d] = { mid = %d, tile = %q, sound = %q, size = %q },", - e[1], e[1], e[2], e[3], e[4]) + " [%d] = { mid = %d, tile = %q, sound = %q, size = %q, tileset = %q },", + e[1], e[1], e[2], e[3], e[4], e[5]) end lines[#lines + 1] = " }" lines[#lines + 1] = "}" diff --git a/src/import/gba/field_effect_extract.lua b/src/import/gba/field_effect_extract.lua index 0b4c2ee2..0a09a401 100644 --- a/src/import/gba/field_effect_extract.lua +++ b/src/import/gba/field_effect_extract.lua @@ -1,16 +1,11 @@ --- Extract FRLG tall-grass + Pokemon Center heal field-effect gfx. --- Tall grass: 5×16×16 @ gFieldEffectObjectPic_TallGrass + general_1 palette. --- Heal machine art is vendored as PNG under chrome/field_effects/ (pret pics). +-- Extract FRLG field-effect graphics directly from ROM. +-- Tall grass, Cut grass leaves, Rock smash rubble, Surf blob, Fly bird, Ripples. local Versions = require("src.import.gba.versions") local FieldEffectExtract = {} FieldEffectExtract.FORMAT_VERSION = 1 -FieldEffectExtract.FRAME_W = 16 -FieldEffectExtract.FRAME_H = 16 -FieldEffectExtract.FRAME_COUNT = 5 -FieldEffectExtract.FRAME_BYTES = 128 -- 4 tiles × 32 local function log(msg) print("[gba/field_effects] " .. tostring(msg)) @@ -26,25 +21,27 @@ local function bgr555_to_rgb8(c) math.floor(b5 * 255 / 31 + 0.5) end ---- Decode one 16×16 4bpp frame (tile order TL, TR, BL, BR). -local function decode_frame(rom, off) - local w, h = FieldEffectExtract.FRAME_W, FieldEffectExtract.FRAME_H +--- Decode one frame (4bpp tiles in row-major layout). +local function decode_frame(rom, off, fw, fh) + local tw = math.floor(fw / 8) + local th = math.floor(fh / 8) local pixels = {} - for i = 1, w * h do pixels[i] = 0 end - local function put_tile(tileOff, ox, oy) - for y = 0, 7 do - for x = 0, 7 do - local byteIndex = tileOff + y * 4 + math.floor(x / 2) - local b = rom:get(byteIndex) - local idx = (x % 2 == 1) and math.floor(b / 16) % 16 or (b % 16) - pixels[(oy + y) * w + (ox + x) + 1] = idx + for i = 1, fw * fh do pixels[i] = 0 end + for ty = 0, th - 1 do + for tx = 0, tw - 1 do + local tileIdx = ty * tw + tx + local tileOff = off + tileIdx * 32 + local ox, oy = tx * 8, ty * 8 + for y = 0, 7 do + for x = 0, 7 do + local byteIndex = tileOff + y * 4 + math.floor(x / 2) + local b = rom:get(byteIndex) + local idx = (x % 2 == 1) and math.floor(b / 16) % 16 or (b % 16) + pixels[(oy + y) * fw + (ox + x) + 1] = idx + end end end end - put_tile(off + 0 * 32, 0, 0) - put_tile(off + 1 * 32, 8, 0) - put_tile(off + 2 * 32, 0, 8) - put_tile(off + 3 * 32, 8, 8) return pixels end @@ -58,16 +55,15 @@ local function load_palette(rom, palOff) return rgb end -local function bake_rgba(rom, picOff, palOff, frames) - frames = frames or FieldEffectExtract.FRAME_COUNT - local w = FieldEffectExtract.FRAME_W - local fh = FieldEffectExtract.FRAME_H +local function bake_rgba(rom, picOff, palOff, fw, fh, frames) + local frameBytes = math.floor(fw / 8) * math.floor(fh / 8) * 32 + local w = fw local h = fh * frames local rgb = load_palette(rom, palOff) local bytes = {} for f = 0, frames - 1 do - local pix = decode_frame(rom, picOff + f * FieldEffectExtract.FRAME_BYTES) - for i = 1, w * fh do + local pix = decode_frame(rom, picOff + f * frameBytes, fw, fh) + for i = 1, fw * fh do local idx = pix[i] or 0 local a = (idx == 0) and 0 or 255 local c = rgb[idx] or { 0, 0, 0 } @@ -82,25 +78,28 @@ function FieldEffectExtract.writeExtract(rom, cache, root, version) version = version or {} if not rom or not cache then return nil, "rom and cache required" end - local picOff = version.field_effect_tall_grass - or Versions.FIELD_EFFECT_TALL_GRASS - local palOff = version.field_effect_pal_general_1 - or Versions.FIELD_EFFECT_PAL_GENERAL_1 - if not picOff or not palOff then - return nil, "missing tall grass / palette offsets" + local effects = Versions.FIELD_EFFECTS or {} + local rel = root .. "/field_effects" + local results = {} + + for name, spec in pairs(effects) do + local picOff = spec.pic + local palOff = spec.pal + if picOff and palOff then + local fw = spec.w or 16 + local fh = spec.h or 16 + local frames = spec.frames or 1 + local rgba, w, h = bake_rgba(rom, picOff, palOff, fw, fh, frames) + cache:write(rel .. "/" .. name .. ".rgba", rgba) + cache:write(rel .. "/" .. name .. ".meta", string.format( + "return { w = %d, h = %d, frames = %d, fw = %d, fh = %d, format = %d }\n", + w, h, frames, fw, fh, FieldEffectExtract.FORMAT_VERSION)) + log(string.format("%s %dx%d (%d frames, %dx%d) → %s", name, w, h, frames, fw, fh, rel)) + results[name] = { path = rel .. "/" .. name .. ".rgba", w = w, h = h, frames = frames } + end end - local rgba, w, h = bake_rgba(rom, picOff, palOff, FieldEffectExtract.FRAME_COUNT) - local rel = root .. "/field_effects" - cache:write(rel .. "/tall_grass.rgba", rgba) - cache:write(rel .. "/tall_grass.meta", string.format( - "return { w = %d, h = %d, frames = %d, fw = %d, fh = %d, format = %d }\n", - w, h, FieldEffectExtract.FRAME_COUNT, - FieldEffectExtract.FRAME_W, FieldEffectExtract.FRAME_H, - FieldEffectExtract.FORMAT_VERSION)) - log(string.format("tall_grass %dx%d (%d frames) → %s", w, h, FieldEffectExtract.FRAME_COUNT, rel)) - - return { path = rel .. "/tall_grass.rgba", w = w, h = h, frames = FieldEffectExtract.FRAME_COUNT } + return results end return FieldEffectExtract diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 3f76ac28..09820fd3 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -124,9 +124,19 @@ Versions.WATER_WILD_COUNT = 5 Versions.ROCK_WILD_COUNT = 5 Versions.FISH_WILD_COUNT = 10 --- Field-effect tall grass (FireRed USA 1.0): pic + FLDEFF general_1 palette. +-- Field-effect graphics & palettes (FireRed USA 1.0) Versions.FIELD_EFFECT_TALL_GRASS = 0x39A008 -- gFieldEffectObjectPic_TallGrass Versions.FIELD_EFFECT_PAL_GENERAL_1 = 0x398FC8 -- gFieldEffectObjectPalette1 +Versions.FIELD_EFFECT_PAL_GENERAL_0 = 0x398FA8 -- gFieldEffectObjectPalette0 + +Versions.FIELD_EFFECTS = { + tall_grass = { pic = 0x39A008, pal = 0x398FC8, w = 16, h = 16, frames = 5 }, + cut_grass = { pic = 0x3986A8, pal = 0x398FC8, w = 16, h = 16, frames = 4 }, + rock_smash = { pic = 0x398928, pal = 0x398FA8, w = 16, h = 16, frames = 4 }, + surf_blob = { pic = 0x396B08, pal = 0x398FC8, w = 32, h = 32, frames = 6 }, + fly_bird = { pic = 0x398048, pal = 0x398FA8, w = 32, h = 32, frames = 4 }, + ripple = { pic = 0x398BA8, pal = 0x398FA8, w = 16, h = 16, frames = 8 }, +} -- Battle interface chrome (FireRed USA 1.0 file offsets; LZ unless noted). -- Healthbox pals are uncompressed INCBIN_U16 (pret graphics.c); matched to healthbox.pal. diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index 37381670..d9d737c6 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -22,6 +22,7 @@ local POCKET_CAPACITY = { local DEFAULT_CAPACITY = 20 local function isBadge(id) + if type(id) ~= "string" then return false end return id:find("BADGE", 1, true) ~= nil end @@ -57,6 +58,7 @@ Bag.isBadge = isBadge -- Occupied slots, of one pocket when named or of the whole inventory when not. function Bag.slots(save, data, pocket) + if not save or not save.inventory then return 0 end local n = 0 for id in pairs(save.inventory) do if not isBadge(id) @@ -70,6 +72,8 @@ end -- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for -- saves from before the order existed, then maintained incrementally. function Bag.order(save, data) + if not save then return {} end + save.inventory = save.inventory or {} local order = save.bagOrder if not order then local items = (data or require("src.core.Data")).items @@ -78,10 +82,14 @@ function Bag.order(save, data) if not isBadge(id) then table.insert(order, id) end end table.sort(order, function(a, b) - local ia = (items and items[a] and items[a].index) or math.huge - local ib = (items and items[b] and items[b].index) or math.huge - if ia ~= ib then return ia < ib end - return a < b + local ia = (items and items[a] and (items[a].index or items[a].itemId)) or math.huge + local ib = (items and items[b] and (items[b].index or items[b].itemId)) or math.huge + if ia ~= ib then + if type(ia) == type(ib) then return ia < ib end + return tostring(ia) < tostring(ib) + end + if type(a) == type(b) then return a < b end + return tostring(a) < tostring(b) end) save.bagOrder = order end @@ -129,6 +137,8 @@ end -- is needed and the bag is full, or when the stack would pass 99 -- (AddItemToInventory's per-slot quantity cap). function Bag.add(save, id, qty, data) + if not save then return false end + save.inventory = save.inventory or {} local inv = save.inventory -- Only the item's OWN pocket has to have room -- a full ITEM pocket does not -- keep a KEY_ITEM or an HM out, which is the whole point of pockets. @@ -146,7 +156,7 @@ function Bag.add(save, id, qty, data) -- next order() pass deduped it (and a save taken in between kept both). local isNew = not inv[id] if isNew and not isBadge(id) then - table.insert(Bag.order(save), id) + table.insert(Bag.order(save, data), id) end inv[id] = (inv[id] or 0) + (qty or 1) return true @@ -154,6 +164,7 @@ end -- Remove qty (default 1); clears the slot and its order entry at zero. function Bag.remove(save, id, qty) + if not save or not save.inventory then return end local inv = save.inventory inv[id] = (inv[id] or 0) - (qty or 1) if inv[id] <= 0 then diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index a54ddb21..74e769f7 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -14,9 +14,11 @@ FrlgFont.LINE_PITCH = 15 -- maxLetterHeight(14) + lineSpacing(1) FrlgFont.COLOR = { NORMAL = { fg = { 98 / 255, 98 / 255, 98 / 255, 1 }, shadow = { 213 / 255, 213 / 255, 205 / 255, 1 } }, - -- MALE: GBA scrolling_bg.pal (color 4 #7BBDFF, shadow color 5 #007BFF) + -- MALE / BLUE: GBA scrolling_bg.pal (color 4 #7BBDFF, shadow color 5 #007BFF) MALE = { fg = { 123 / 255, 189 / 255, 255 / 255, 1 }, shadow = { 0 / 255, 123 / 255, 255 / 255, 1 } }, + BLUE = { fg = { 123 / 255, 189 / 255, 255 / 255, 1 }, + shadow = { 0 / 255, 123 / 255, 255 / 255, 1 } }, -- FEMALE: GBA scrolling_bg.pal (color 6 #FF8383, shadow color 7 #AC1818) FEMALE = { fg = { 255 / 255, 131 / 255, 131 / 255, 1 }, shadow = { 172 / 255, 24 / 255, 24 / 255, 1 } }, diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index feeba0f7..181c31e6 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -60,7 +60,11 @@ function Message.frameKind() end function Message.show(text, opts) - opts = opts or {} + if type(opts) == "function" then + opts = { done = opts } + elseif type(opts) ~= "table" then + opts = {} + end Message.open = true Message._stay = opts.stay and true or false Message._done = opts.done diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 4dedb56d..71888f6f 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -495,6 +495,64 @@ function PartyMenu.handleInput(input) se(5) PartyMenu.mode = "item_action" PartyMenu.itemActionCursor = 1 + elseif PartyMenu._fieldMoveNames and PartyMenu._fieldMoveNames[act] then + local FieldMoves = require("src.core.game3.field_moves") + local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + if act == "SOFTBOILED" or act == "MILK DRINK" then + local maxHp = mon and (mon.maxHp or (mon.stats and mon.stats.hp)) or 0 + local cost = math.floor(maxHp / 5) + local curHp = mon and (mon.hp or 0) or 0 + if curHp <= cost or cost <= 0 then + se(9) + PartyMenu.showMessage("Not enough HP!", function() + PartyMenu.mode = "list" + end) + return + end + se(5) + PartyMenu._softboiledDonorSlot = PartyMenu.cursor + PartyMenu.mode = "softboiled" + return + else + local P = package.loaded["src.core.game3.player"] or require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local Objects = require("src.core.game3.objects") + local Map = require("src.core.game3.map") + local Space = package.loaded["src.core.game3.scripting.space"] + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local d = DELTA[P.facing or "down"] or DELTA.down + local fx, fy = P.cellX + d[1], P.cellY + d[2] + local facingObj = Objects.at(fx, fy) + local isWater = Collision.isWater and Collision.isWater(fx, fy) + local isGrass = Collision.isGrass and Collision.isGrass(fx, fy) + local mapDef = Map.currentDef() + local ctx = { + party = PartyMenu._party, + mon = mon, + store = Space and Space.store, + session = PartyMenu._session, + facingObject = facingObj, + isFacingWater = isWater, + isSurfing = P.surfing == true, + hasCuttableGrass = isGrass or (Collision.isGrass and Collision.isGrass(P.cellX, P.cellY)), + mapType = mapDef and mapDef.type, + } + local res = FieldMoves.fromMenu(act, ctx) + if not res or not res.ok then + se(9) + PartyMenu.showMessage((res and res.text) or "Can't use that here.", function() + PartyMenu.mode = "list" + end) + else + se(5) + PartyMenu.close() + local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") + if Field.executeFieldMove then + Field.executeFieldMove(res) + end + end + return + end else se(9) PartyMenu.mode = PartyMenu._previousMode or "list" @@ -506,6 +564,34 @@ function PartyMenu.handleInput(input) return end + if PartyMenu.mode == "softboiled" then + local FieldMoves = require("src.core.game3.field_moves") + if input:wasPressed("up") then + PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 + se(5) + elseif input:wasPressed("down") then + PartyMenu.cursor = (PartyMenu.cursor % n) + 1 + se(5) + elseif input:wasPressed("a") then + local userMon = PartyMenu._party and PartyMenu._party[PartyMenu._softboiledDonorSlot] + local targetMon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + local ok, userHp, targetHp = FieldMoves.softboiledTransfer(userMon, targetMon) + if not ok then + se(9) + PartyMenu.showMessage("It won't have any effect.", function() + PartyMenu.mode = "softboiled" + end) + else + se(11) + PartyMenu.mode = "list" + end + elseif input:wasPressed("b") then + se(9) + PartyMenu.mode = "list" + end + return + end + -- Battle switch mode if PartyMenu.mode == "battle_switch" then if input:wasPressed("up") then @@ -708,11 +794,30 @@ function PartyMenu.handleInput(input) elseif input:wasPressed("a") then se(5) local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - if mon and mon.isEgg then - PartyMenu.ACTIONS = { "SUMMARY", "SWITCH", "CANCEL" } - else - PartyMenu.ACTIONS = { "SUMMARY", "SWITCH", "ITEM", "CANCEL" } + local FieldMoves = require("src.core.game3.field_moves") + local actions = {} + local fmNames = {} + if mon and not mon.isEgg and mon.moves then + for _, m in ipairs(mon.moves) do + local mId = FieldMoves.normalizeMoveId(m) + local mName = mId and FieldMoves.MOVE_NAME_BY_ID[mId] + if mName then + local label = mName:gsub("_", " ") + if not fmNames[label] then + actions[#actions + 1] = label + fmNames[label] = true + end + end + end end + actions[#actions + 1] = "SUMMARY" + actions[#actions + 1] = "SWITCH" + if not (mon and mon.isEgg) then + actions[#actions + 1] = "ITEM" + end + actions[#actions + 1] = "CANCEL" + PartyMenu.ACTIONS = actions + PartyMenu._fieldMoveNames = fmNames PartyMenu.mode = "action" PartyMenu.actionCursor = 1 elseif input:wasPressed("b") or input:wasPressed("start") then @@ -829,7 +934,9 @@ function PartyMenu.draw() if i == PartyMenu.actionCursor then Window.cursorPx(popX * 8 + 1, rowY) end - FrlgFont.draw(act, popX * 8 + 9, rowY, { colors = FrlgFont.COLOR.NORMAL }) + local isFm = PartyMenu._fieldMoveNames and PartyMenu._fieldMoveNames[act] + local col = isFm and (FrlgFont.COLOR.BLUE or FrlgFont.COLOR.MALE) or FrlgFont.COLOR.NORMAL + FrlgFont.draw(act, popX * 8 + 9, rowY, { colors = col }) end else Window.stdFrame(Window.template(1, 15, 28, 4)) diff --git a/tests/game3_doors_test.lua b/tests/game3_doors_test.lua index 36515560..5451ddc7 100644 --- a/tests/game3_doors_test.lua +++ b/tests/game3_doors_test.lua @@ -304,4 +304,122 @@ assert_true(oakAnim ~= nil, "Oak lab door anim created") assert_true(oakAnim.tile ~= nil, "Anim has tile name assigned") Doors.reset() +-- 13. Gatehouse North & South Smooth Transitions (Route 2 East Building) +print("[Test 13] Gatehouse North and South smooth passage transitions") +Collision.clear() +local mockGateMap = { + width = 15, + height = 12, + warps = { + { x = 6, y = 10, destMap = "FR_ROUTE_2", destWarp = 6 }, + { x = 7, y = 10, destMap = "FR_ROUTE_2", destWarp = 6 }, + { x = 8, y = 10, destMap = "FR_ROUTE_2", destWarp = 6 }, + { x = 7, y = 1, destMap = "FR_ROUTE_2", destWarp = 7 }, + } +} +Collision._mapId = "FR_ROUTE2_EAST_BUILDING" +Collision._widthCells = 15 +Collision._heightCells = 12 +Collision._grid = {} +for i = 1, 15 * 12 do Collision._grid[i] = 0x00 end +-- Set North and South exits to standard warp mat / floor +Collision._grid[1 * 15 + 7 + 1] = 0x72 +Collision._grid[10 * 15 + 7 + 1] = 0x72 +Collision._grid[0 * 15 + 7 + 1] = 0x07 -- solid wall above north exit +Collision._grid[11 * 15 + 7 + 1] = 0x07 -- solid wall below south exit +Collision.installWarps(mockGateMap) + +-- 1. Gatehouse entrances/exits are NOT classified as animated house doors +assert_eq(Collision.isDoorWarp(nil, 7, 1), nil, "North exit (7, 1) is not an animated door") +assert_eq(Collision.isDoorWarp(nil, 7, 10), nil, "South exit (7, 10) is not an animated door") +assert_eq(Collision.isExitWarp(nil, 7, 10), nil, "South exit (7, 10) is not an animated door exit (outdoor landing has no door)") + +-- 2. Stepping on North exit (7, 1) triggers smooth fade warp +local requestedWarp = nil +Warp.request = function(mod, g, destMap, destX, destY, facing, opts) + requestedWarp = { destMap = destMap, destX = destX, destY = destY, facing = facing, opts = opts } + return true +end + +local warpedNorth = Collision.tryWarpAt(nil, 7, 1, "up") +assert_true(warpedNorth == true, "Stepping onto North exit (7, 1) warps player") +assert_true(requestedWarp ~= nil, "Warp request created") +assert_eq(requestedWarp.destMap, "FR_ROUTE_2", "Destination map is FR_ROUTE_2") +assert_eq(requestedWarp.facing, "up", "Facing preserved as up") +assert_eq(requestedWarp.opts.door, false, "door is false (smooth fade transition, not house door)") + +-- 3. Moving DOWN at South boundary (7, 10) triggers smooth fade warp +requestedWarp = nil +Player.reset(7, 10, "down") +local result = Player.tryMove("down", nil, false) +assert_eq(result, "warp", "Player moving down on south exit triggers boundary warp") +assert_true(requestedWarp ~= nil, "Warp request created for south exit") +assert_eq(requestedWarp.destMap, "FR_ROUTE_2", "Destination map is FR_ROUTE_2") +assert_eq(requestedWarp.facing, "down", "Facing is down") +assert_eq(requestedWarp.opts.door, false, "door is false for gatehouse south exit") + +-- 14. Live Map Door vs Non-Door Identification Tests (Route 2, Gatehouses, Town Doors) +print("[Test 14] Live map door identification via ROM metatile lookup") +local function checkDoorAt(mapId, x, y) + local entry, info = Doors.getDoorEntryAt(mapId, x, y) + return entry ~= nil, entry and entry.tile +end + +-- Pallet Town houses & lab +local ok1, t1 = checkDoorAt("FR_PALLET_TOWN", 6, 7) +assert_true(ok1, "Player house is detected as door") +assert_eq(t1, "Pallet", "Player house door is Pallet") + +local ok2, t2 = checkDoorAt("FR_PALLET_TOWN", 16, 13) +assert_true(ok2, "Oak lab is detected as door") +assert_eq(t2, "OaksLab", "Oak lab door is OaksLab") + +-- Route 2 house is a door, but Route 2 East Building entrances are NOT +local ok3, t3 = checkDoorAt("FR_ROUTE_2", 17, 22) +assert_true(ok3, "Route 2 house is detected as door") +assert_eq(t3, "General", "Route 2 house door is General") + +local ok4, _ = checkDoorAt("FR_ROUTE_2", 18, 46) +assert_eq(ok4, false, "Route 2 East Building south entrance is NOT an animated door") + +local ok5, _ = checkDoorAt("FR_ROUTE_2", 18, 41) +assert_eq(ok5, false, "Route 2 East Building north entrance is NOT an animated door") + +-- Viridian City +local ok6, t6 = checkDoorAt("FR_VIRIDIAN_CITY", 26, 26) +assert_true(ok6, "Viridian Pokecenter is detected as door") +assert_eq(t6, "SlidingSingle", "Viridian Pokecenter door is SlidingSingle") + +local ok7, t7 = checkDoorAt("FR_VIRIDIAN_CITY", 36, 10) +assert_true(ok7, "Viridian Gym is detected as door") +assert_eq(t7, "SlidingDouble", "Viridian Gym door is SlidingDouble") + +local ok8, t8 = checkDoorAt("FR_VIRIDIAN_CITY", 25, 11) +assert_true(ok8, "Viridian House is detected as door") +assert_eq(t8, "Viridian", "Viridian House door is Viridian") + +-- Pewter City Museum +local ok9, _ = checkDoorAt("FR_PEWTER_CITY", 17, 6) +assert_eq(ok9, false, "Pewter Museum glass front entrance is NOT an animated door (smooth passage)") + +local ok10, t10 = checkDoorAt("FR_PEWTER_CITY", 25, 4) +assert_true(ok10, "Pewter Museum side door is detected as door") +assert_eq(t10, "Pewter", "Pewter Museum side door is Pewter") + +-- Cave ladders (Diglett's Cave, Rock Tunnel, Mt. Moon) +local ok11, _ = checkDoorAt("FR_DIGLETTS_CAVE_NORTH_ENTRANCE", 6, 4) +assert_eq(ok11, false, "Digletts Cave North ladder is NOT a door") + +local ok12, _ = checkDoorAt("FR_DIGLETTS_CAVE_B1F", 3, 3) +assert_eq(ok12, false, "Digletts Cave B1F ladder to North Room is NOT a door") + +local ok13, _ = checkDoorAt("FR_DIGLETTS_CAVE_B1F", 82, 71) +assert_eq(ok13, false, "Digletts Cave B1F ladder to South Room is NOT a door") + +local ok14, _ = checkDoorAt("FR_ROCK_TUNNEL_1F", 45, 2) +assert_eq(ok14, false, "Rock Tunnel ladder is NOT a door") + +local ok15, _ = checkDoorAt("FR_MT_MOON_1F", 5, 6) +assert_eq(ok15, false, "Mt Moon ladder is NOT a door") + print("--- All game3_doors_test PASSED! ---") diff --git a/tests/game3_field_moves_test.lua b/tests/game3_field_moves_test.lua new file mode 100644 index 00000000..b8e4cbd8 --- /dev/null +++ b/tests/game3_field_moves_test.lua @@ -0,0 +1,162 @@ +-- Tests for Game 3 (FRLG) Field Moves & Field Effects +-- Validates: +-- 1. Pure ROM extraction of field effects (tall grass, cut grass, rock smash, surf blob, fly bird, ripple) +-- 2. Softboiled / Milk Drink donor limits and HP transfer math +-- 3. 3x3 Grass mowing and metatile behavior checks +-- 4. Pushable boulder collision and direction logic +-- 5. Surfing collision, bike override, and landing dismount +-- 6. Strength flag lifecycle (map load reset) +-- 7. Party menu action generation with field moves + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format("Assertion failed: %s (expected %s, got %s)", + tostring(msg), tostring(expected), tostring(actual))) + end +end + +local function assert_true(cond, msg) + if not cond then + error("Assertion failed: " .. tostring(msg)) + end +end + +print("=== 1. Testing Field Effect ROM Extraction ===") +local FxExtract = require("src.import.gba.field_effect_extract") +local Versions = require("src.import.gba.versions") + +local f = io.open("./1636 - Pokemon Fire Red (U)(Squirrels).gba", "rb") +assert_true(f ~= nil, "FireRed ROM found") +local data = f:read("*a") +f:close() + +local rom = { + data = data, + size = #data, + get = function(self, off) return self.data:byte(off + 1) end, + u16 = function(self, off) + local b1, b2 = self.data:byte(off + 1, off + 2) + return b1 + b2 * 256 + end, + u32 = function(self, off) + local b1, b2, b3, b4 = self.data:byte(off + 1, off + 4) + return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216 + end, +} + +local cacheFiles = {} +local cache = { + write = function(self, path, content) + cacheFiles[path] = content + end, + read = function(self, path) + return cacheFiles[path] + end, +} + +local res = FxExtract.writeExtract(rom, cache, "data/generated/gba", Versions) +assert_true(type(res) == "table", "writeExtract returned table") +assert_true(cacheFiles["data/generated/gba/field_effects/tall_grass.rgba"] ~= nil, "tall_grass.rgba exists") +assert_true(cacheFiles["data/generated/gba/field_effects/cut_grass.rgba"] ~= nil, "cut_grass.rgba exists") +assert_true(cacheFiles["data/generated/gba/field_effects/rock_smash.rgba"] ~= nil, "rock_smash.rgba exists") +assert_true(cacheFiles["data/generated/gba/field_effects/surf_blob.rgba"] ~= nil, "surf_blob.rgba exists") +assert_true(cacheFiles["data/generated/gba/field_effects/fly_bird.rgba"] ~= nil, "fly_bird.rgba exists") +assert_true(cacheFiles["data/generated/gba/field_effects/ripple.rgba"] ~= nil, "ripple.rgba exists") +print(" ✓ All 6 field effect RGBA sheets successfully extracted from ROM") + +print("\n=== 2. Testing Softboiled / Milk Drink Limits ===") +local FieldMoves = require("src.core.game3.field_moves") + +-- Donor with exactly 20% HP (e.g. 20/100) -> cost is 20 -> curHp <= cost -> BLOCKED +local chanseyLow = { name = "CHANSEY", hp = 20, maxHp = 100 } +local res1 = FieldMoves.softboiledFromMenu({ mon = chanseyLow }) +assert_eq(res1.ok, false, "Softboiled blocked when curHp == cost (prevent faint)") +assert_eq(res1.text, FieldMoves.TEXT.NOT_ENOUGH_HP, "Not enough HP message") + +-- Donor with 21/100 HP -> cost is 20 -> curHp > cost -> ALLOWED +local chanseyOk = { name = "CHANSEY", hp = 21, maxHp = 100 } +local res2 = FieldMoves.softboiledFromMenu({ mon = chanseyOk }) +assert_eq(res2.ok, true, "Softboiled allowed when curHp > cost") +assert_eq(res2.cost, 20, "Cost is math.floor(maxHp/5)") + +-- Recipient checks +local pikachuHealthy = { name = "PIKACHU", hp = 50, maxHp = 50 } +assert_eq(FieldMoves.softboiledTargetOk(chanseyOk, pikachuHealthy), false, "Full HP mon is not valid recipient") + +local eggMon = { isEgg = true, hp = 10, maxHp = 50 } +assert_eq(FieldMoves.softboiledTargetOk(chanseyOk, eggMon), false, "Egg is not valid recipient") + +local faintedMon = { name = "CHARMANDER", hp = 0, maxHp = 50 } +assert_eq(FieldMoves.softboiledTargetOk(chanseyOk, faintedMon), false, "Fainted mon is not valid recipient") + +local pikachuHurt = { name = "PIKACHU", hp = 10, maxHp = 50 } +assert_eq(FieldMoves.softboiledTargetOk(chanseyOk, pikachuHurt), true, "Hurt mon is valid recipient") + +-- Execution +local okTransfer, userHp, targetHp = FieldMoves.softboiledTransfer(chanseyOk, pikachuHurt, 20) +assert_eq(okTransfer, true, "Transfer successful") +assert_eq(chanseyOk.hp, 1, "Chansey reduced to 1 HP") +assert_eq(pikachuHurt.hp, 30, "Pikachu restored to 30 HP") +print(" ✓ Softboiled donor limits and HP transfer math verified") + +print("\n=== 3. Testing 3x3 Grass Mowing ===") +local grid = { + ["1,1"] = 0x00D, -- Plain_Grass + ["2,1"] = 0x00A, -- ThinTreeTop_Grass + ["3,1"] = 0x000, -- Not grass + ["1,2"] = 0x00B, -- WideTreeTopLeft_Grass + ["2,2"] = 0x00C, -- WideTreeTopRight_Grass + ["3,2"] = 0x000, + ["1,3"] = 0x000, + ["2,3"] = 0x000, + ["3,3"] = 0x000, +} +local function getMid(x, y) return grid[x .. "," .. y] or 0 end +local function setMid(x, y, mid) grid[x .. "," .. y] = mid end + +local cutCount = FieldMoves.mowGrass3x3(2, 2, getMid, setMid) +assert_eq(cutCount, 4, "Mowed 4 tall grass tiles in 3x3") +assert_eq(grid["1,1"], 0x001, "Plain_Grass became Plain_Mowed") +assert_eq(grid["2,1"], 0x013, "ThinTreeTop_Grass became ThinTreeTop_Mowed") +assert_eq(grid["1,2"], 0x00E, "WideTreeTopLeft_Grass became WideTreeTopLeft_Mowed") +assert_eq(grid["2,2"], 0x00F, "WideTreeTopRight_Grass became WideTreeTopRight_Mowed") +print(" ✓ 3x3 metatile grass mowing verified") + +print("\n=== 4. Testing Pushable Boulder Logic ===") +local boulder = { x = 5, y = 5 } +local blockedCells = { ["5,4"] = true } +local function isPassable(x, y) + return not blockedCells[x .. "," .. y] +end + +local canPushUp = FieldMoves.canPushBoulder(boulder, "up", isPassable) +assert_eq(canPushUp, false, "Cannot push boulder into blocked cell") + +local canPushDown, destX, destY = FieldMoves.canPushBoulder(boulder, "down", isPassable) +assert_eq(canPushDown, true, "Can push boulder into open cell") +assert_eq(destX, 5, "Dest X is 5") +assert_eq(destY, 6, "Dest Y is 6") +print(" ✓ Pushable boulder direction and collision verified") + +print("\n=== 5. Testing Surfing & Bike Override ===") +local Player = require("src.core.game3.player") +Player.biking = true +Player.surfing = false +Player.startSurfing({}) +assert_eq(Player.surfing, true, "Player is surfing") +assert_eq(Player.biking, false, "Bike state silently cleared on Surf") +print(" ✓ Surfing and bike override verified") + +print("\n=== 6. Testing Message.show with callback function ===") +local Message = require("src.ui.game3.message") +local callbackRan = false +Message.show("This tree can be Cut!", function() callbackRan = true end) +assert_eq(Message.open, true, "Message is open") +assert_true(type(Message._done) == "function", "Message._done is a function") +Message.close() +assert_eq(callbackRan, true, "Message done callback executed on close") +print(" ✓ Message.show function callback verified") + +print("\n==========================================") +print("ALL GAME 3 FIELD MOVE TESTS PASSED!") +print("==========================================") diff --git a/tests/game3_item_use_and_parcel_test.lua b/tests/game3_item_use_and_parcel_test.lua index 128c2abe..31f6eea6 100644 --- a/tests/game3_item_use_and_parcel_test.lua +++ b/tests/game3_item_use_and_parcel_test.lua @@ -227,4 +227,57 @@ assert(PartyMenu.isOpen() == false, "PartyMenu closed after potion used") assert(BagMenu.mode == "list", "BagMenu returned to list mode") print("[ok] BagMenu USE Potion -> PartyMenu -> heal passed") +print("[test] 7. Ground item ball pickup via std:1 sets object flag and removes object") +local Space = require("src.core.game3.scripting.space") +local Objects = require("src.core.game3.objects") +local Flags = require("src.core.game3.scripting.flags") + +Space.store = Flags.newStore() +Space.active = true + +local itemBallDef = { + localId = 5, + index = 5, + x = 17, + y = 54, + flag = 340, + graphics = 92, + graphicsId = 92, + sprite = "SPRITE_POKE_BALL", + scriptKey = "test_item_ball", +} +Objects.loadMap(nil, "TEST_MAP", { objects = { itemBallDef } }) +local spawned = Objects.find(5) +assert(spawned ~= nil, "Item ball spawned") +assert(spawned.visible == true, "Item ball initially visible") +assert(Space.objectVisible(itemBallDef) == true, "objectVisible true before pickup") + +local testItemScript = { + { op = "setorcopyvar", [1] = 0x8000, [2] = 34 }, -- Great Ball + { op = "setorcopyvar", [1] = 0x8001, [2] = 1 }, + { op = "callstd", [1] = 1, std = 1 }, + { op = "end" }, +} + +local Field = require("src.core.game3.field") +Field._session = { bag = testBag } + +local Adapters = require("src.core.game3.scripting.adapters") +local adapters = Adapters.host(nil, nil, nil) +Space.vm = Vm.new({ + store = Space.store, + scripts = { test_item_ball = testItemScript }, + adapters = adapters, +}) + +local prevCount = Bag.get(testBag, 34) or 0 +Space.startScript("test_item_ball", 5, 1) + +assert(Bag.get(testBag, 34) == prevCount + 1, "Great Ball added to bag") +assert(Flags.getFlag(Space.store, nil, 340) == true, "Flag 340 set by item pickup") +assert(spawned.visible == false, "Item ball object hidden") +assert(Space.objectVisible(itemBallDef) == false, "objectVisible false after pickup") +print("[ok] Ground item pickup sets flag and removes object") + print("[test] all passed successfully!") + diff --git a/tests/save_editor_gen3_tests.lua b/tests/save_editor_gen3_tests.lua new file mode 100644 index 00000000..09668456 --- /dev/null +++ b/tests/save_editor_gen3_tests.lua @@ -0,0 +1,566 @@ +-- Headless Gen 3 (FireRed) save-editor and launcher slot summary tests. +-- Run with: luajit tests/save_editor_gen3_tests.lua + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local Gen = require("Gen") +local MonOps = require("MonOps") +local Catalog = require("Catalog") +local Ops = require("Ops") +local Flags = require("src.core.game3.scripting.flags") +local Schema = require("src.core.game3.save_schema_firered") + +local passed = 0 +local failed = 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. tostring(msg)) + end +end + +local function checkEq(actual, expected, msg) + if actual == expected then + passed = passed + 1 + else + failed = failed + 1 + print(("FAIL: %s (got %s, want %s)"):format(tostring(msg), tostring(actual), tostring(expected))) + end +end + +print("== save editor gen3 tests ==") + +-- -------------------------------------------------------------------------- +-- 1. Launcher Slot Summary +-- -------------------------------------------------------------------------- +do + local save = { + version = "firered", + engine = "game3", + generation = 3, + name = "ASH", + dex = { + seen = { [1] = true, [4] = true, [7] = true, [25] = true }, + owned = { [1] = true, [4] = true, [25] = true }, + }, + playTime = { hours = 14, minutes = 25, seconds = 30 }, + flags = { + [0x820] = true, -- BOULDER + [0x821] = true, -- CASCADE + [0x822] = true, -- THUNDER + }, + } + + local name, meta = SaveData.slotSummary(save) + checkEq(name, "ASH", "slotSummary derives player name") + checkEq(meta.dexCount, 3, "slotSummary derives Gen 3 dex count from dex.owned") + checkEq(meta.timeText, "14:25", "slotSummary formats Gen 3 playTime table") + checkEq(meta.badges, 3, "slotSummary counts Gen 3 badges from flags") +end + +-- Slot summary with string flag keys and playtime field +do + local save = { + version = "firered", + playerName = "RED", + dex = { + caught = { ["BULBASAUR"] = true, ["CHARMANDER"] = true }, + }, + playtime = { hours = 2, minutes = 5, seconds = 0 }, + flags = { + ["2080"] = true, -- 0x820 + ["2081"] = true, -- 0x821 + ["2082"] = true, -- 0x822 + ["2083"] = true, -- 0x823 + ["2084"] = true, -- 0x824 + }, + } + + local name, meta = SaveData.slotSummary(save) + checkEq(name, "RED", "slotSummary derives playerName") + checkEq(meta.dexCount, 2, "slotSummary derives dex count from dex.caught") + checkEq(meta.timeText, "2:05", "slotSummary formats playtime") + checkEq(meta.badges, 5, "slotSummary counts badges from string flag keys") +end + +-- -------------------------------------------------------------------------- +-- 2. Gen.of and Generation Helpers +-- -------------------------------------------------------------------------- +do + checkEq(Gen.of({ version = "firered" }), 3, "Gen.of returns 3 for version=firered") + checkEq(Gen.of({ engine = "game3" }), 3, "Gen.of returns 3 for engine=game3") + checkEq(Gen.of({ generation = 3 }), 3, "Gen.of returns 3 for generation=3") + checkEq(Gen.of(nil, "firered"), 3, "Gen.of returns 3 for version arg firered") + check(Gen.hasPlayerGender({ version = "firered" }), "Gen.hasPlayerGender true for FireRed") + checkEq(Gen.boxCount({ version = "firered" }), 14, "Gen.boxCount returns 14 for FireRed") + checkEq(Gen.boxCapacity({ version = "firered" }), 30, "Gen.boxCapacity returns 30 for FireRed") +end + +-- -------------------------------------------------------------------------- +-- 3. Data Binding (Gen.bindGame3Data) +-- -------------------------------------------------------------------------- +local mockData = {} +Gen.bindGame3Data(mockData) + +do + check(type(mockData.pokemon) == "table", "bindGame3Data creates pokemon catalog") + check(mockData.pokemon.BULBASAUR ~= nil or mockData.pokemon[1] ~= nil, "pokemon catalog has Bulbasaur") + check(mockData.pokemon.CHARIZARD ~= nil or mockData.pokemon[6] ~= nil, "pokemon catalog has Charizard") + + check(type(mockData.moves) == "table", "bindGame3Data creates moves catalog") + check(type(mockData.items) == "table", "bindGame3Data creates items catalog") + check(type(mockData.game3Maps) == "table" or type(mockData.maps) == "table", "bindGame3Data creates maps catalog") +end + +-- -------------------------------------------------------------------------- +-- 4. Pokémon Creation & Editing with MonOps +-- -------------------------------------------------------------------------- +do + local mon = MonOps.create(mockData, 6, 50, 3) -- Charizard Lv50 + check(type(mon) == "table", "MonOps.create creates Gen 3 mon") + checkEq(mon.level, 50, "mon level is 50") + check(mon.stats ~= nil, "mon stats populated") + check(mon.stats.hp > 0, "mon HP calculated") + check(mon.stats.attack > 0, "mon attack calculated") + check(mon.stats.defense > 0, "mon defense calculated") + check(mon.stats.specialAttack > 0, "mon specialAttack calculated") + check(mon.stats.specialDefense > 0, "mon specialDefense calculated") + check(mon.stats.speed > 0, "mon speed calculated") + check(#mon.moves > 0, "mon has default moves at Lv50") + + -- Set Level + local prevHp = mon.stats.hp + MonOps.setLevel(mockData, mon, 100, 3) + checkEq(mon.level, 100, "mon level updated to 100") + check(mon.stats.hp > prevHp, "Lv100 stats higher than Lv50") + + -- Set Species + MonOps.setSpecies(mockData, mon, 9, 3) -- Blastoise + checkEq(mon.speciesId, 9, "mon speciesId updated to 9") + check(mon.name == "BLASTOISE" or mon.species == "BLASTOISE", "mon name updated to BLASTOISE") + + -- Set Move + MonOps.setMove(mockData, mon, 1, 57) -- Surf + checkEq(mon.moves[1].moveId, 57, "move 1 set to Surf (57)") + check(mon.moves[1].pp > 0, "move 1 PP initialized") + + -- Set DVs / IVs + MonOps.setDv(mockData, mon, "attack", 15, 3) + checkEq(mon.dvs.attack, 15, "mon attack DV set to 15") + checkEq(mon.ivs.atk, 31, "mon attack IV mapped to 31") +end + +-- -------------------------------------------------------------------------- +-- 5. Badge Operations +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + local badgeIds = Gen.badgeIds(save, nil) + checkEq(#badgeIds, 8, "Gen.badgeIds returns 8 Kanto badges") + + checkEq(Gen.hasBadge(save, "BOULDERBADGE"), false, "new game has no Boulder Badge") + checkEq(Gen.hasBadge(save, "CASCADEBADGE"), false, "new game has no Cascade Badge") + + -- Toggle Boulder Badge ON + local nowOn = Gen.toggleBadge(save, "BOULDERBADGE") + checkEq(nowOn, true, "toggleBadge turns Boulder Badge ON") + checkEq(Gen.hasBadge(save, "BOULDERBADGE"), true, "hasBadge confirms Boulder Badge ON") + checkEq(Flags.hasBadge(save, "BOULDER"), true, "Flags.hasBadge confirms Boulder ON") + + -- Toggle Cascade Badge ON + Gen.toggleBadge(save, "CASCADEBADGE") + checkEq(Gen.hasBadge(save, "CASCADEBADGE"), true, "Cascade Badge ON") + + -- Slot summary badge count + local _, meta = SaveData.slotSummary(save) + checkEq(meta.badges, 2, "slotSummary reflects 2 badges earned") + + -- Toggle Boulder Badge OFF + local nowOff = Gen.toggleBadge(save, "BOULDERBADGE") + checkEq(nowOff, false, "toggleBadge turns Boulder Badge OFF") + checkEq(Gen.hasBadge(save, "BOULDERBADGE"), false, "Boulder Badge is now OFF") + + local _, meta2 = SaveData.slotSummary(save) + checkEq(meta2.badges, 1, "slotSummary reflects 1 badge earned") +end + +-- -------------------------------------------------------------------------- +-- 6. Map & Location Operations +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + local map, x, y, facing = Gen.playerMap(save) + check(map ~= nil, "playerMap returns map") + check(type(x) == "number", "playerMap returns numeric x") + check(type(y) == "number", "playerMap returns numeric y") + + -- Move player to Pewter City + Gen.setPlayerHere(save, "FR_PEWTER_CITY", 14, 25, "up") + local newMap, nx, ny, nFacing = Gen.playerMap(save) + checkEq(newMap, "FR_PEWTER_CITY", "setPlayerHere updates map to FR_PEWTER_CITY") + checkEq(nx, 14, "setPlayerHere updates x to 14") + checkEq(ny, 25, "setPlayerHere updates y to 25") + checkEq(nFacing, "up", "setPlayerHere updates facing to up") + checkEq(save.position.map, "FR_PEWTER_CITY", "save.position.map in sync") + + -- Ops.setLastHeal + local state = { save = save, mapId = "FR_CERULEAN_CITY", mapClickCell = { cx = 19, cy = 17 } } + Ops.setLastHeal(state) + checkEq(save.healMap, "FR_CERULEAN_CITY", "Ops.setLastHeal updates save.healMap") + checkEq(save.healX, 19, "Ops.setLastHeal updates save.healX") + checkEq(save.healY, 17, "Ops.setLastHeal updates save.healY") +end + +-- -------------------------------------------------------------------------- +-- 7. Flags & Events +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + checkEq(Gen.getFlag(save, "FLAG_GOT_STARTER"), false, "FLAG_GOT_STARTER false initially") + + Gen.setFlag(save, "FLAG_GOT_STARTER", true) + checkEq(Gen.getFlag(save, "FLAG_GOT_STARTER"), true, "Gen.getFlag reads set flag") + + Gen.setFlag(save, "FLAG_GOT_STARTER", false) + checkEq(Gen.getFlag(save, "FLAG_GOT_STARTER"), false, "Gen.getFlag reads cleared flag") + + local events = Catalog.game3EventList() + check(type(events) == "table", "Catalog.game3EventList returns table") + check(#events > 50, "game3EventList contains many game3 flags") +end + +-- -------------------------------------------------------------------------- +-- 8. Money & Coins Operations +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + checkEq(Gen.money(save), 3000, "initial money is 3000") + Gen.setMoney(save, 50000) + checkEq(Gen.money(save), 50000, "setMoney updates money to 50000") + checkEq(save.money, 50000, "save.money is 50000") + + checkEq(Gen.coins(save), 0, "initial coins is 0") + Gen.setCoins(save, 9999) + checkEq(Gen.coins(save), 9999, "setCoins updates coins to 9999") + checkEq(save.coins, 9999, "save.coins is 9999") +end + +-- -------------------------------------------------------------------------- +-- 9. Gender Operations +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED", gender = 0 }) + checkEq(Gen.playerGender(save), "male", "gender 0 is male") + Gen.setPlayerGender(save, "female") + checkEq(Gen.playerGender(save), "female", "setPlayerGender updates to female") + checkEq(save.gender, 1, "save.gender is 1") +end + +-- -------------------------------------------------------------------------- +-- 10. Save Encoding & Decoding Round-Trip +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + Gen.setMoney(save, 77777) + Gen.setCoins(save, 500) + Gen.setPlayerHere(save, "FR_CELADON_CITY", 20, 15, "right") + Gen.toggleBadge(save, "RAINBOWBADGE") + Gen.setFlag(save, "FLAG_SYS_POKEDEX_GET", true) + + local mon = MonOps.create(mockData, 25, 25, 3) -- Pikachu Lv25 + save.party = { mon } + save.dex = { owned = { [25] = true }, seen = { [25] = true } } + save.playTime = { hours = 5, minutes = 45, seconds = 12 } + + local encoded = SaveData.encode(save) + check(type(encoded) == "string" and #encoded > 0, "SaveData.encode produces valid string") + + local decoded = SaveData.decode(encoded) + check(type(decoded) == "table", "SaveData.decode decodes back to table") + checkEq(decoded.money, 77777, "round-trip preserves money") + checkEq(decoded.coins, 500, "round-trip preserves coins") + checkEq(decoded.map, "FR_CELADON_CITY", "round-trip preserves map") + checkEq(decoded.x, 20, "round-trip preserves x") + checkEq(decoded.y, 15, "round-trip preserves y") + checkEq(Gen.hasBadge(decoded, "RAINBOWBADGE"), true, "round-trip preserves Rainbow Badge") + checkEq(Gen.getFlag(decoded, "FLAG_SYS_POKEDEX_GET"), true, "round-trip preserves Pokedex flag") + + local name, meta = SaveData.slotSummary(decoded) + checkEq(name, "RED", "round-trip slotSummary name") + checkEq(meta.dexCount, 1, "round-trip slotSummary dexCount") + checkEq(meta.timeText, "5:45", "round-trip slotSummary timeText") + checkEq(meta.badges, 1, "round-trip slotSummary badges") +end + +-- -------------------------------------------------------------------------- +-- 11. App.load Full Integration Test +-- -------------------------------------------------------------------------- +do + local prevLove = _G.love + _G.love = _G.love or {} + _G.love.filesystem = _G.love.filesystem or { + getSaveDirectory = function() return "." end, + getInfo = function() return nil end, + getDirectoryItems = function() return {} end, + read = function() return nil end, + exists = function() return false end, + } + local App = require("tools.save-editor.App") + local ok, err = pcall(App.load, nil, { version = "firered", slotId = "slot1", embedded = true }) + check(ok, "App.load runs without error on firered: " .. tostring(err)) + local S = App.getState() + check(S ~= nil, "App state created") + checkEq(Gen.of(S.save, S.version), 3, "App state save is Gen 3") + check(type(S.cat) == "table", "App state has Catalog") + check(#S.cat.species > 150, "App state catalog has all species") + check(#S.cat.moves > 165, "App state catalog has all moves") + check(#S.cat.items > 50, "App state catalog has all items") + check(#S.events > 100, "App state has game3 events") + App.unload() + _G.love = prevLove +end + +-- -------------------------------------------------------------------------- +-- 12. Mon Hydration & Name Normalization with Numeric Species +-- -------------------------------------------------------------------------- +do + local rawMon = { + species = 1, -- Bulbasaur numeric ID + speciesId = 1, + level = 5, + ivs = { hp = 15, atk = 14, def = 13, spe = 12, spa = 11, spd = 10 }, + } + Gen.hydrateMon(mockData, rawMon) + checkEq(rawMon.species, "BULBASAUR", "hydrateMon normalizes numeric species to BULBASAUR") + checkEq(rawMon.speciesId, 1, "hydrateMon preserves speciesId = 1") + check(type(rawMon.dvs) == "table", "hydrateMon creates dvs table") + checkEq(rawMon.dvs.attack, 7, "hydrateMon computes attack DV from IV") + checkEq(rawMon.dvs.defense, 6, "hydrateMon computes defense DV from IV") + check(rawMon.stats ~= nil and rawMon.stats.hp > 0, "hydrateMon computes valid stats") +end + +-- -------------------------------------------------------------------------- +-- 13. MonEditor & Species Usability Tests +-- -------------------------------------------------------------------------- +do + local S = { + data = mockData, + version = "firered", + save = Schema.newGame({ name = "RED" }), + } + checkEq(Ops.speciesUsable(S, "BULBASAUR"), true, "BULBASAUR is usable in Gen 3") + checkEq(Ops.speciesUsable(S, 1), true, "Numeric species 1 is usable in Gen 3") + checkEq(Ops.speciesUsable(S, "CHARIZARD"), true, "CHARIZARD is usable in Gen 3") + checkEq(Ops.speciesUsable(S, "MEWTWO"), true, "MEWTWO is usable in Gen 3") + checkEq(Ops.speciesUsable(S, 384), true, "Rayquaza (384) is usable in Gen 3") + + local mon = MonOps.create(mockData, "BULBASAUR", 5, 3) + S.editingMon = mon + check(mon.dvs ~= nil, "created mon has dvs") + + -- Test MonOps.setSpecies + Ops.setSpecies(S, mon, "CHARMANDER") + checkEq(mon.species, "CHARMANDER", "Ops.setSpecies updates to CHARMANDER") + checkEq(mon.speciesId, 4, "Ops.setSpecies updates speciesId to 4") +end + +-- -------------------------------------------------------------------------- +-- 14. MapBrowser Gen 3 Map Adapter & inBounds Safety +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + local S = { + data = mockData, + version = "firered", + save = save, + mapId = "FR_PALLET_TOWN", + mapZoom = 2, + mapCamX = 0, + mapCamY = 0, + } + local def = Gen.maps(mockData)[S.mapId] + check(def ~= nil, "FR_PALLET_TOWN def exists in mockData") + + local mw = def.width or 20 + local mh = def.height or 18 + local midLayout = def.midLayout + local map = { + id = S.mapId, + def = def, + width = mw, + height = mh, + widthCells = mw, + heightCells = mh, + warps = def.warps or {}, + inBounds = function(self, cx, cy) + return cx >= 0 and cx < self.width and cy >= 0 and cy < self.height + end, + warpAtCell = function(self, cx, cy) + for _, w in ipairs(self.warps or {}) do + if w.x == cx and w.y == cy then return { def = w } end + end + return nil + end, + tileAtCell = function(self, cx, cy) + if midLayout and midLayout.midAt then + return midLayout:midAt(cx, cy) or 0 + end + return 0 + end, + } + + check(map:inBounds(5, 5) == true, "inBounds true for (5,5)") + check(map:inBounds(-1, 5) == false, "inBounds false for (-1,5)") + check(map:inBounds(100, 100) == false, "inBounds false for (100,100)") + check(map:tileAtCell(5, 5) ~= nil, "tileAtCell returns tile") +end + +-- -------------------------------------------------------------------------- +-- 15. Bag, PC, & Items Panel Operations +-- -------------------------------------------------------------------------- +do + local save = Schema.newGame({ name = "RED" }) + local S = { + data = mockData, + version = "firered", + save = save, + cat = Catalog.build(mockData, save, "firered"), + } + Gen.hydrateSave(mockData, save) + check(type(save.inventory) == "table", "save.inventory initialized") + check(type(save.pcItems) == "table", "save.pcItems initialized") + + -- Add to bag + Ops.addToBag(S, "POTION") + checkEq(save.inventory["POTION"], 1, "POTION added to bag x1") + check(save.bag ~= nil and save.bag.stacks ~= nil, "save.bag updated") + + -- Adjust bag quantity + Ops.bagAdjust(S, "POTION", 4) + checkEq(save.inventory["POTION"], 5, "POTION adjusted to x5") + + -- Max bag stack + Ops.bagMax(S, "POTION") + checkEq(save.inventory["POTION"], Ops.STACK_MAX, "POTION maxed to 99") + + -- Drop bag item + Ops.bagDrop(S, "POTION") + checkEq(save.inventory["POTION"], nil, "POTION dropped from bag") + + -- PC operations + Ops.addToPc(S, "POKE_BALL") + checkEq(save.pcItems["POKE_BALL"], 1, "POKE_BALL added to PC x1") + check(save.pc ~= nil and #save.pc.items > 0, "save.pc.items synced") + + Ops.pcAdjust(S, "POKE_BALL", 9) + checkEq(save.pcItems["POKE_BALL"], 10, "POKE_BALL adjusted to x10 in PC") + + Ops.pcMax(S, "POKE_BALL") + checkEq(save.pcItems["POKE_BALL"], Ops.STACK_MAX, "POKE_BALL maxed in PC") + + -- Test numeric item IDs in Bag.order, Bag.slots, and isBadge + save.inventory[13] = 5 -- Potion numeric ID + save.inventory[4] = 10 -- Poke Ball numeric ID + save.inventory["BOULDERBADGE"] = true -- Badge string flag + + local Bag = require("src.inventory.Bag") + checkEq(Bag.isBadge(13), false, "Bag.isBadge(13) is false") + checkEq(Bag.isBadge("BOULDERBADGE"), true, "Bag.isBadge('BOULDERBADGE') is true") + checkEq(Ops.isBadgeId(13), false, "Ops.isBadgeId(13) is false") + checkEq(Ops.isBadgeId("BOULDERBADGE"), true, "Ops.isBadgeId('BOULDERBADGE') is true") + + local order = Bag.order(save, mockData) + check(type(order) == "table", "Bag.order returns table with numeric IDs") + checkEq(Bag.slots(save, mockData), 2, "Bag.slots counts 2 items excluding badge") +end + +-- -------------------------------------------------------------------------- +-- 16. Tab Transitions & Items Panel Drawing +-- -------------------------------------------------------------------------- +do + local prevLove = _G.love + _G.love = _G.love or {} + _G.love.filesystem = _G.love.filesystem or { + getSaveDirectory = function() return "." end, + getInfo = function() return nil end, + getDirectoryItems = function() return {} end, + read = function() return nil end, + exists = function() return false end, + } + _G.love.graphics = _G.love.graphics or { + getDimensions = function() return 1024, 768 end, + setFont = function() end, + setColor = function() end, + rectangle = function() end, + setScissor = function() end, + getScissor = function() return nil end, + push = function() end, + pop = function() end, + translate = function() end, + scale = function() end, + print = function() end, + printf = function() end, + draw = function() end, + } + _G.love.mouse = _G.love.mouse or { + getPosition = function() return 100, 100 end, + isDown = function() return false end, + } + + local App = require("tools.save-editor.App") + local ok, err = pcall(App.load, nil, { version = "firered", slotId = "slot1", embedded = true }) + check(ok, "App.load runs: " .. tostring(err)) + + local S = App.getState() + check(S ~= nil, "State exists") + + -- Add items to bag and PC + Ops.addToBag(S, "POTION") + Ops.addToBag(S, 4) -- Poke ball numeric ID + Ops.addToPc(S, "MASTER_BALL") + Ops.addToPc(S, 13) -- Potion numeric ID + + -- Switch to Boxes tab + S.tab = "boxes" + local okB, errB = pcall(App.draw) + check(okB, "App.draw renders boxes tab without error: " .. tostring(errB)) + + -- Switch from Boxes to Items tab + S.tab = "items" + local okI, errI = pcall(App.draw) + check(okI, "App.draw renders items tab (wide) without error: " .. tostring(errI)) + + -- Test stacked layout (narrow screen) + _G.love.graphics.getDimensions = function() return 480, 800 end + local okS, errS = pcall(App.draw) + check(okS, "App.draw renders items tab (stacked) without error: " .. tostring(errS)) + + -- Test sorting + Ops.bagSort(S, "name") + Ops.pcSort(S, "name") + local okSort, errSort = pcall(App.draw) + check(okSort, "App.draw renders after sorting items: " .. tostring(errSort)) + + -- Test table-typed entries in save.inventory and save.pcItems + S.save.inventory[1] = { id = 13, qty = 5 } + S.save.inventory["POTION"] = { qty = 10 } + S.save.pcItems[1] = { id = 4, qty = 20 } + check(pcall(Ops.bagCanMax, S), "Ops.bagCanMax handles table entries in inventory without error") + check(pcall(Ops.pcCanMax, S), "Ops.pcCanMax handles table entries in pcItems without error") + local okT, errT = pcall(App.draw) + check(okT, "App.draw renders with table-typed items: " .. tostring(errT)) + + App.unload() + _G.love = prevLove +end + +print(string.format("save editor gen3 tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 4948c208..db3f7fb0 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -109,7 +109,9 @@ local function applyLoaded(path, statusVerb) S.loadError = false S.allowSave = true end - if Gen.of(S.save, S.version) == 2 then + if Gen.of(S.save, S.version) == 3 then + S.events = Catalog.game3EventList(S.modRoots) + elseif Gen.of(S.save, S.version) == 2 then S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots) end local mapId = Gen.playerMap(S.save) @@ -130,7 +132,7 @@ local function applyLoaded(path, statusVerb) #(S.validation.lostScriptMem or {}), #(S.validation.lostMail or {}), #(S.validation.lostEvents or {})) - else + elseif Gen.of(S.save, S.version) ~= 3 then S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps", #S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps) end @@ -163,7 +165,10 @@ function App.load(pathOverride, opts) -- loaded at least once, so it doubles as the "needs evicting" marker. if Data._pristineKeys then Data:unloadGenerated() end Data:load() - if Gen.of(nil, opts.version) == 2 + if Gen.of(nil, opts.version) == 3 + or require("src.core.GameVersion").generation() == 3 then + Gen.bindGame3Data(Data) + elseif Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then Gen.bindGoldData(Data) end @@ -179,7 +184,9 @@ function App.load(pathOverride, opts) modRoots[#modRoots + 1] = mod.path end S.modRoots = modRoots - if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then + if Gen.of(nil, opts.version) == 3 or require("src.core.GameVersion").generation() == 3 then + S.events = Catalog.game3EventList(modRoots) + elseif Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots) else S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", @@ -829,8 +836,15 @@ function App.draw() local contentH = sh - railH - titleH - tabH - statusH local panel = PANELS[S.tab] if panel then - panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s, - sw - 44 * s, contentH - 38 * s) + local ok, err = xpcall(function() + panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s, + sw - 44 * s, contentH - 38 * s) + end, debug.traceback) + if not ok then + Kit.resetClip() + print(string.format("[SAVE-EDITOR ERROR in %s panel]\n%s", tostring(S.tab), tostring(err))) + Kit.text("mono", "Error rendering " .. tostring(S.tab) .. " panel", ox + 22 * s, contentY + 20 * s, PAL.red) + end end drawStatusBar(ox, oy + sh - statusH, sw, statusH) diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 2bd27988..02a9bdfe 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -15,7 +15,9 @@ end local function sortedRecordKeys(t) local keys = {} for k, v in pairs(t or {}) do - if type(v) == "table" then table.insert(keys, k) end + if type(k) == "string" and type(v) == "table" then + table.insert(keys, k) + end end table.sort(keys) return keys @@ -143,4 +145,27 @@ function Catalog.goldEventList(extraDirs) return Catalog.gen2EventList("gs", extraDirs) end +function Catalog.game3EventList(extraDirs) + local okF, FlagsTable = pcall(require, "src.core.game3.scripting.flags_table") + local names = {} + local seen = {} + if okF and FlagsTable and FlagsTable.FLAGS then + for name, id in pairs(FlagsTable.FLAGS) do + if type(name) == "string" and not seen[name] then + names[#names + 1] = name + seen[name] = true + end + end + end + local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs) + for _, name in ipairs(modFlags) do + if not seen[name] then + names[#names + 1] = name + seen[name] = true + end + end + table.sort(names) + return names +end + return Catalog diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 3b978f55..89a763ab 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -1,6 +1,6 @@ --- Generation adapter for the save editor. Panels stay generation-blind; --- Ops and App read Gold vs RBY through this module so a Gold write never --- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...). +-- Generation adapter for the save editor. Panels stay generation-blind; +-- Ops and App read Gold vs RBY vs FRLG through this module so a write never +-- lands in wrong-generation fields. local GameVersion = require("src.core.GameVersion") @@ -15,6 +15,8 @@ end function Gen.of(save, version) if type(save) == "table" then + if save.generation == 3 or save.engine == "game3" or save.version == "firered" then return 3 end + if save.healMap ~= nil or (save.bag and save.bag.pockets ~= nil) or (save.dex and save.dex.national ~= nil) then return 3 end if save.generation == 2 then return 2 end local fromVersion = versionGeneration(save.version) if fromVersion then return fromVersion end @@ -41,6 +43,9 @@ end function Gen.editionLabel(save, version) local info = GameVersion.info(versionOf(save, version)) + if Gen.of(save, version) == 3 then + return tostring((info and info.label) or "FIRE RED"):upper() + end return tostring((info and info.label) or "GEN 2"):upper() end @@ -49,10 +54,12 @@ function Gen.hasCaughtData(save, version) return require("src.battle.gen2.Mon").hasCaughtData(versionOf(save, version)) end --- engine/menus/init_gender.asm:23-38 +-- engine/menus/init_gender.asm:23-38 (Crystal), FRLG Oak intro (gender 0/1) function Gen.hasPlayerGender(save, version) - if Gen.of(save, version) ~= 2 then return false end - return Gen.engineOf(save, version) == "crystal" + local g = Gen.of(save, version) + if g == 3 then return true end + if g == 2 then return Gen.engineOf(save, version) == "crystal" end + return false end function Gen.ofState(S) @@ -64,11 +71,10 @@ function Gen.is2(save, version) return Gen.of(save, version) == 2 end --- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod --- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded --- cache so a mod patch that landed on an empty gen2Maps (objects only, no --- width) does not hide the extractor's record, and a new map like BERRY_FARM --- still appears. +function Gen.is3(save, version) + return Gen.of(save, version) == 3 +end + local function overlayRecords(base, overlay) if not overlay then return base or {} end if not base or base == overlay then return overlay end @@ -90,16 +96,17 @@ end function Gen.maps(data) if type(data) ~= "table" then return {} end - return overlayRecords(data.maps, data.gen2Maps) + local m = overlayRecords(data.maps, data.gen2Maps) + return overlayRecords(m, data.game3Maps) end function Gen.tilesets(data) if type(data) ~= "table" then return {} end - return overlayRecords(data.tilesets, data.gen2Tilesets) + local t = overlayRecords(data.tilesets, data.gen2Tilesets) + return overlayRecords(t, data.game3Tilesets) end --- Point the Gen 2 Data keys at the tables Data:load already filled, before --- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold. +-- Point the Gen 2 Data keys at the tables Data:load already filled function Gen.bindGoldData(data) if type(data) ~= "table" then return data end if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end @@ -126,11 +133,6 @@ function Gen.bindGoldData(data) end data.gen2Palettes = data.gen2Palettes or loadGen("palettes") - -- Namespaced AND differently shaped in Schemas.GEN2 (the cart's ordered - -- name lists, not Gen 1's rule table), same as palettes/icons below -- - -- omitting it left mod.content.constants:get(...) reading an empty table - -- under a Gold save-editor boot, which is what misreads "generation" and - -- rejects every record a mod shapes off it. data.gen2Constants = data.gen2Constants or loadGen("constants") data.gen2Icons = data.gen2Icons or loadGen("icons") data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex") @@ -140,10 +142,94 @@ function Gen.bindGoldData(data) return data end +-- Bind Game 3 (FireRed) data into Data table for Save Editor +function Gen.bindGame3Data(data) + if type(data) ~= "table" then return data end + local okD, Dataset = pcall(require, "src.core.game3.dataset") + if okD and Dataset then + if Dataset.mountExtractRoots then pcall(Dataset.mountExtractRoots) end + local g3Maps = Dataset.buildMaps() + if g3Maps then + local cache = Dataset.cache and Dataset.cache() + if cache and Dataset.attachMidLayouts then + pcall(Dataset.attachMidLayouts, g3Maps, cache) + end + local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native") + if okN and NativeTileset and NativeTileset.install and cache then + pcall(NativeTileset.install, cache, nil) + end + data.game3Maps = g3Maps + data.maps = overlayRecords(data.maps, g3Maps) + end + end + + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon then + pcall(Pokemon.install, nil) + data.pokemon = data.pokemon or {} + for id = 1, 412 do + local name = Pokemon.name(id) + if name and name ~= "??????????" and name ~= "" then + local def = { + id = name, + name = name, + species = id, + speciesId = id, + dex = id, + growthRate = (Pokemon.speciesMeta and Pokemon.speciesMeta(id) and Pokemon.speciesMeta(id).growthRate) or 0, + } + data.pokemon[name] = def + data.pokemon[id] = def + end + end + + data.moves = data.moves or {} + for id = 1, 354 do + local mName = Pokemon.moveName(id) + local bMove = Pokemon.battleMove(id) + if mName and mName ~= "-------" and not mName:find("^MOVE %d+") then + local mDef = { + id = mName, + moveId = id, + name = mName, + pp = (bMove and tonumber(bMove.pp)) or 10, + power = (bMove and tonumber(bMove.power)) or 40, + type = (bMove and bMove.type) or "NORMAL", + } + data.moves[mName] = mDef + data.moves[id] = mDef + end + end + end + + local okI, ItemsData = pcall(require, "src.core.game3.items_data") + if okI and ItemsData then + data.items = data.items or {} + for k, v in pairs(ItemsData.BY_HOST or {}) do + local iDef = { id = k, name = v.name or k, pocket = v.pocket, itemId = v.frlg } + data.items[k] = iDef + end + for num = 1, 375 do + local info = ItemsData.info(num) + if info and info.name and info.name ~= "none" and info.name ~= "" then + local normName = info.name:upper():gsub("[^A-Z0-9_]", "_"):gsub("_+", "_") + local iDef = { id = normName, name = info.name, pocket = info.pocket, itemId = num } + data.items[normName] = iDef + data.items[num] = iDef + end + end + end + + return data +end + function Gen.newGame(version) local id = type(version) == "string" and GameVersion.VERSIONS[version] and version or nil - if (versionGeneration(id) or GameVersion.generation()) == 2 then + local g = versionGeneration(id) or GameVersion.generation() + if g == 3 then + return require("src.core.game3.save_schema_firered").newGame({ version = id }) + elseif g == 2 then local Save2 = require("src.core.gen2.Save") local save = Save2.newGame({ playerName = id and Save2.defaultPlayerName(id) or nil, @@ -155,14 +241,21 @@ function Gen.newGame(version) end function Gen.validate(save, data) - if Gen.of(save) == 2 then + local g = Gen.of(save) + if g == 3 then + return { lostMons = {}, lostItems = {}, remappedMaps = {} } + elseif g == 2 then return require("src.core.gen2.Save").validate(save) end return require("src.core.SaveData").validate(save, data) end function Gen.emptyReport(save, report) - if Gen.of(save) == 2 then + local g = Gen.of(save) + if g == 3 then + if not report then return true end + return #(report.lostMons or {}) == 0 and #(report.lostItems or {}) == 0 and #(report.remappedMaps or {}) == 0 + elseif g == 2 then return require("src.core.gen2.Save").emptyReport(report) end return require("src.core.SaveData").emptyReport(report) @@ -170,6 +263,42 @@ end function Gen.hydrateMon(data, mon) if type(mon) ~= "table" then return mon end + local isG3 = mon.speciesId ~= nil or mon.personality ~= nil or mon.ivs ~= nil or (mon.evs ~= nil and mon.evs.spa ~= nil) + if isG3 then + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon then + local spId = tonumber(mon.speciesId or mon.species) + or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(mon.species))) + if spId then + mon.speciesId = spId + local name = Pokemon.name(spId) + if name and name ~= "" and name ~= "??????????" then + mon.species = name + end + end + mon.ivs = mon.ivs or { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } + mon.evs = mon.evs or { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } + mon.dvs = mon.dvs or { + attack = math.floor((mon.ivs.atk or 0) / 2), + defense = math.floor((mon.ivs.def or 0) / 2), + speed = math.floor((mon.ivs.spe or 0) / 2), + special = math.floor(((mon.ivs.spa or 0) + (mon.ivs.spd or 0)) / 4), + hp = math.floor((mon.ivs.hp or 0) / 2), + } + Pokemon.applyStats(mon) + mon.stats = { + hp = mon.maxHp or mon.hp or 10, + attack = mon.attack or 10, + defense = mon.defense or 10, + speed = mon.speed or 10, + spAtk = mon.spAtk or mon.spa or 10, + spDef = mon.spDef or mon.spd or 10, + specialAttack = mon.spAtk or mon.spa or 10, + specialDefense = mon.spDef or mon.spd or 10, + } + end + return mon + end local def = data and data.pokemon and data.pokemon[mon.species] local gen2 = (mon.stats and mon.stats.specialAttack) or (def and def.baseStats and def.baseStats.specialAttack) @@ -182,10 +311,98 @@ function Gen.hydrateMon(data, mon) return mon end --- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare. function Gen.hydrateSave(data, save) if type(save) ~= "table" then return save end - if Gen.of(save) == 2 then + local g = Gen.of(save) + if g == 3 then + save.inventory = save.inventory or {} + save.bagOrder = save.bagOrder or {} + save.pcItems = save.pcItems or {} + save.pc = save.pc or { items = {} } + save.bag = save.bag or require("src.core.game3.bag").new() + + if save.inventory then + local arraySlots = {} + for k, v in pairs(save.inventory) do + if type(k) == "number" and type(v) == "table" then + arraySlots[#arraySlots + 1] = k + local sId = v.id or v.itemId or v.name or v[1] + local sQty = tonumber(v.qty or v.quantity or v.count or v[2]) or 1 + if sId and sQty > 0 then + local okD, ItemsData = pcall(require, "src.core.game3.items_data") + local okI, Items = pcall(require, "src.core.game3.items") + local num = okD and ItemsData and ItemsData.toNumericId(sId) + local name = (num and okI and Items and Items.FRLG_TO_HOST[num]) or (okD and ItemsData and ItemsData.bagKey(sId)) or tostring(sId) + save.inventory[name] = sQty + end + end + end + for _, k in ipairs(arraySlots) do + save.inventory[k] = nil + end + end + + if save.bag and save.bag.pockets then + local okD, ItemsData = pcall(require, "src.core.game3.items_data") + local okI, Items = pcall(require, "src.core.game3.items") + if okD and ItemsData and okI and Items then + for _, pocketList in pairs(save.bag.pockets) do + for _, slot in ipairs(pocketList or {}) do + if slot.id and (tonumber(slot.qty) or 0) > 0 then + local num = ItemsData.toNumericId(slot.id) + local name = (num and Items.FRLG_TO_HOST[num]) or ItemsData.bagKey(slot.id) or tostring(slot.id) + save.inventory[name] = tonumber(slot.qty) or 1 + end + end + end + end + end + + if save.pcItems then + local arraySlots = {} + for k, v in pairs(save.pcItems) do + if type(k) == "number" and type(v) == "table" then + arraySlots[#arraySlots + 1] = k + local sId = v.id or v.itemId or v.name or v[1] + local sQty = tonumber(v.qty or v.quantity or v.count or v[2]) or 1 + if sId and sQty > 0 then + local okD, ItemsData = pcall(require, "src.core.game3.items_data") + local okI, Items = pcall(require, "src.core.game3.items") + local num = okD and ItemsData and ItemsData.toNumericId(sId) + local name = (num and okI and Items and Items.FRLG_TO_HOST[num]) or (okD and ItemsData and ItemsData.bagKey(sId)) or tostring(sId) + save.pcItems[name] = sQty + end + end + end + for _, k in ipairs(arraySlots) do + save.pcItems[k] = nil + end + end + + if save.pc and type(save.pc.items) == "table" then + local okD, ItemsData = pcall(require, "src.core.game3.items_data") + local okI, Items = pcall(require, "src.core.game3.items") + if okD and ItemsData and okI and Items then + for _, slot in ipairs(save.pc.items) do + local id = slot.id or slot.itemId + local qty = slot.qty or slot.quantity or 1 + if id and (tonumber(qty) or 0) > 0 then + local num = ItemsData.toNumericId(id) + local name = (num and Items.FRLG_TO_HOST[num]) or ItemsData.bagKey(id) or tostring(id) + save.pcItems[name] = tonumber(qty) or 1 + end + end + end + end + + for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end + for _, box in ipairs(save.boxes or {}) do + if type(box) == "table" then + for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end + end + end + return save + elseif g == 2 then local Mon = require("src.battle.gen2.Mon") Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end) return save @@ -203,7 +420,15 @@ function Gen.hydrateSave(data, save) end function Gen.ensureBoxes(save) - if Gen.of(save) == 2 then + local g = Gen.of(save) + if g == 3 then + save.boxes = save.boxes or {} + for i = 1, 14 do + save.boxes[i] = save.boxes[i] or {} + end + save.currentBox = math.max(1, math.min(14, save.currentBox or 1)) + return save.boxes + elseif g == 2 then local Boxes2 = require("src.core.gen2.Boxes") save.boxes = save.boxes or {} for i = 1, Boxes2.NUM_BOXES do @@ -216,20 +441,21 @@ function Gen.ensureBoxes(save) end function Gen.boxCount(save) - if Gen.of(save) == 2 then - return require("src.core.gen2.Boxes").NUM_BOXES - end + local g = Gen.of(save) + if g == 3 then return 14 end + if g == 2 then return require("src.core.gen2.Boxes").NUM_BOXES end return require("src.pokemon.Boxes").COUNT end function Gen.boxCapacity(save) - if Gen.of(save) == 2 then - return require("src.core.gen2.Boxes").MONS_PER_BOX - end + local g = Gen.of(save) + if g == 3 then return 30 end + if g == 2 then return require("src.core.gen2.Boxes").MONS_PER_BOX end return require("src.pokemon.Boxes").CAPACITY end function Gen.money(save) + if type(save) ~= "table" then return 0 end if Gen.of(save) == 2 then return (save.player and save.player.money) or 0 end @@ -237,6 +463,7 @@ function Gen.money(save) end function Gen.setMoney(save, amount) + if type(save) ~= "table" then return end if Gen.of(save) == 2 then save.player = save.player or {} save.player.money = amount @@ -246,6 +473,7 @@ function Gen.setMoney(save, amount) end function Gen.coins(save) + if type(save) ~= "table" then return 0 end if Gen.of(save) == 2 then return (save.player and save.player.coins) or 0 end @@ -253,6 +481,7 @@ function Gen.coins(save) end function Gen.setCoins(save, amount) + if type(save) ~= "table" then return end if Gen.of(save) == 2 then save.player = save.player or {} save.player.coins = amount @@ -261,19 +490,24 @@ function Gen.setCoins(save, amount) end end --- constants/ram_constants.asm:176-177 function Gen.playerGender(save) - local stored = save and save.player and save.player.gender - return stored == "female" and "female" or "male" + if type(save) ~= "table" then return "male" end + if save.gender == 1 or (save.player and (save.player.gender == 1 or save.player.gender == "female")) then + return "female" + end + return "male" end function Gen.setPlayerGender(save, gender) - save.player = save.player or {} - save.player.gender = (gender == "female") and "female" or "male" - return save.player.gender + if type(save) ~= "table" then return "male" end + local isFemale = (gender == "female" or gender == 1) + save.gender = isFemale and 1 or 0 + if save.player then + save.player.gender = isFemale and "female" or "male" + end + return isFemale and "female" or "male" end --- constants/landmark_constants.asm:3, :111-113 function Gen.landmarkName(data, index) index = math.floor(tonumber(index) or 0) local Mon = require("src.battle.gen2.Mon") @@ -295,7 +529,15 @@ function Gen.dexOwnedKey(save) end function Gen.playerMap(save) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return "FR_PALLET_TOWN", 0, 0 end + local g = Gen.of(save) + if g == 3 then + local map = save.map or (save.position and save.position.map) or (save.player and save.player.map) or "FR_PALLET_TOWN" + local x = save.x or (save.position and save.position.x) or (save.player and save.player.x) or 0 + local y = save.y or (save.position and save.position.y) or (save.player and save.player.y) or 0 + local facing = save.facing or (save.position and save.position.facing) or "down" + return map, x, y, facing + elseif g == 2 then local p = save.position if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end if type(save.spawn) == "table" then @@ -310,7 +552,26 @@ function Gen.playerMap(save) end function Gen.setPlayerHere(save, mapId, x, y, facing) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return end + local g = Gen.of(save) + if g == 3 then + save.map = mapId + save.x = x + save.y = y + save.facing = facing or save.facing or "down" + save.position = { + map = mapId, + x = x, + y = y, + facing = save.facing, + } + if save.player then + save.player.map = mapId + save.player.x = x + save.player.y = y + end + return + elseif g == 2 then local prev = save.position or {} save.position = { map = mapId, @@ -335,7 +596,12 @@ local KANTO = { } function Gen.badgeIds(save, cat) - if Gen.of(save) == 2 then + local g = Gen.of(save) + if g == 3 then + local ids = {} + for _, name in ipairs(KANTO) do ids[#ids + 1] = name .. "BADGE" end + return ids + elseif g == 2 then local ids = {} for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end for _, name in ipairs(KANTO) do ids[#ids + 1] = name end @@ -352,7 +618,15 @@ local KANTO_SET = {} for _, name in ipairs(KANTO) do KANTO_SET[name] = true end function Gen.hasBadge(save, id) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return false end + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + return Flags.hasBadge(save, id) + end + return save.flags and (save.flags[id] or save.flags[id:gsub("BADGE$", "")]) == true + elseif g == 2 then local p = save.player or {} local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {}) if store[id] then return true end @@ -366,7 +640,20 @@ function Gen.hasBadge(save, id) end function Gen.toggleBadge(save, id) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return false end + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + local on = Flags.hasBadge(save, id) + Flags.setBadge(save, id, not on) + return not on + end + local on = Gen.hasBadge(save, id) + save.flags = save.flags or {} + save.flags[id] = (not on) and true or nil + return not on + elseif g == 2 then save.player = save.player or {} local storeName = KANTO_SET[id] and "kantoBadges" or "badges" save.player[storeName] = save.player[storeName] or {} @@ -378,6 +665,7 @@ function Gen.toggleBadge(save, id) end return not on end + save.inventory = save.inventory or {} local on = save.inventory[id] and true or false save.inventory[id] = (not on) and 1 or nil return not on @@ -388,7 +676,15 @@ local function gen2FlagId(save, name) end function Gen.getFlag(save, name) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return false end + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + return Flags.getFlag(save, nil, name) + end + return save.flags and (save.flags[name] == true or save.flags[tostring(name)] == true) + elseif g == 2 then local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") @@ -402,7 +698,18 @@ function Gen.getFlag(save, name) end function Gen.setFlag(save, name, on) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return end + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + Flags.setFlag(save, nil, name, on) + return + end + save.flags = save.flags or {} + save.flags[name] = on and true or nil + return + elseif g == 2 then local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") @@ -421,7 +728,9 @@ function Gen.setFlag(save, name, on) end function Gen.flagCount(save) - if Gen.of(save) == 2 then + if type(save) ~= "table" then return 0 end + local g = Gen.of(save) + if g == 2 then local n = 0 for _ in pairs(save.events or {}) do n = n + 1 end for _ in pairs(save.flags or {}) do n = n + 1 end @@ -433,7 +742,7 @@ function Gen.flagCount(save) end function Gen.exp(mon) - return mon.experience or mon.exp or 0 + return mon.exp or mon.experience or 0 end return Gen diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index e74c207d..039931c0 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -5,7 +5,87 @@ local Growth = require("src.pokemon.Growth") local MonOps = {} function MonOps.create(data, species, level, gen) - if gen == 2 then + level = math.max(1, math.min(100, math.floor(tonumber(level) or 5))) + if gen == 3 then + local PokemonG3 = require("src.core.game3.pokemon") + local SummaryData = require("src.core.game3.summary_data") + pcall(PokemonG3.install, nil) + + local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1 + local name = (PokemonG3.name and PokemonG3.name(spId)) or tostring(species) + + local personality = (love and love.math and love.math.random and love.math.random(0, 0xFFFFFFFF)) + or (math.floor(math.random() * 0x100000000) % 0x100000000) + + local ivs = {} + for _, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do + ivs[k] = (love and love.math and love.math.random and love.math.random(0, 31)) or math.random(0, 31) + end + + local meta = PokemonG3.speciesMeta and PokemonG3.speciesMeta(spId) + local friendship = (meta and meta.friendship) or 70 + local growthRate = (meta and tonumber(meta.growthRate)) or 0 + local ability = PokemonG3.abilityId and PokemonG3.abilityId(spId, personality) or 0 + local gender = PokemonG3.gender and PokemonG3.gender(spId, personality) or "U" + + local moves, pp, maxPp = {}, {}, {} + if PokemonG3.movesAtLevel then + moves, pp, maxPp = PokemonG3.movesAtLevel(spId, level) + end + + local monMoves = {} + for slot = 1, 4 do + local mvId = moves[slot] + if mvId and mvId > 0 then + local mvName = PokemonG3.moveName(mvId) + monMoves[slot] = { + id = mvName, + moveId = mvId, + pp = pp[slot] or 10, + maxPp = maxPp[slot] or 10, + } + end + end + + local mon = { + species = name, + speciesId = spId, + name = name, + nickname = "", + level = level, + growthRate = growthRate, + exp = SummaryData.expForLevel(growthRate, level), + moves = monMoves, + personality = personality, + nature = PokemonG3.natureId and PokemonG3.natureId(personality) or 0, + ivs = ivs, + dvs = { + attack = math.floor(ivs.atk / 2), + defense = math.floor(ivs.def / 2), + speed = math.floor(ivs.spe / 2), + special = math.floor(ivs.spa / 2), + hp = math.floor(ivs.hp / 2), + }, + evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, + ability = ability, + gender = gender, + happiness = friendship, + friendship = friendship, + } + + PokemonG3.applyStats(mon) + mon.stats = { + hp = mon.maxHp, + attack = mon.attack, + defense = mon.defense, + speed = mon.speed, + spAtk = mon.spAtk, + spDef = mon.spDef, + specialAttack = mon.spAtk, + specialDefense = mon.spDef, + } + return mon + elseif gen == 2 then local Mon = require("src.battle.gen2.Mon") local mon = Mon.new(data, species, level) assert(mon, "unknown species " .. tostring(species)) @@ -15,60 +95,124 @@ function MonOps.create(data, species, level, gen) end function MonOps.recalc(data, mon, gen) + if type(mon) ~= "table" then return end + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + or (mon.ivs ~= nil) or (mon.evs ~= nil and mon.evs.spa ~= nil) + if isG3 then + local okP, PokemonG3 = pcall(require, "src.core.game3.pokemon") + if okP and PokemonG3 then + PokemonG3.applyStats(mon) + mon.stats = { + hp = mon.maxHp or mon.hp or 10, + attack = mon.attack or 10, + defense = mon.defense or 10, + speed = mon.speed or 10, + spAtk = mon.spAtk or mon.spa or 10, + spDef = mon.spDef or mon.spd or 10, + specialAttack = mon.spAtk or mon.spa or 10, + specialDefense = mon.spDef or mon.spd or 10, + } + mon.hp = math.max(0, math.min(mon.hp or mon.maxHp, mon.maxHp)) + end + return + end if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then require("src.battle.gen2.Mon").refreshStats(mon, data) return end - local def = data.pokemon[mon.species] + local def = data and data.pokemon and data.pokemon[mon.species] assert(def, "unknown species") mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp)) end function MonOps.setLevel(data, mon, level, gen) + if type(mon) ~= "table" then return end level = math.max(1, math.min(100, math.floor(level))) - local def = data.pokemon[mon.species] + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) or (mon.ivs ~= nil) mon.level = level + + if isG3 then + local SummaryData = require("src.core.game3.summary_data") + local gr = mon.growthRate or (data and data.pokemon and data.pokemon[mon.species] and data.pokemon[mon.species].growthRate) or 0 + mon.exp = SummaryData.expForLevel(gr, level) + mon.experience = mon.exp + MonOps.recalc(data, mon, gen) + return + end + + local def = data and data.pokemon and data.pokemon[mon.species] if gen == 2 or mon.experience ~= nil then local Mon = require("src.battle.gen2.Mon") - local growth = Mon.growthFor(data, def.growthRate) + local growth = Mon.growthFor(data, def and def.growthRate) mon.experience = Mon.experienceForLevel(growth, level) Mon.refreshStats(mon, data) return end - mon.exp = Growth.expForLevel(def.growthRate, level) + mon.exp = Growth.expForLevel(def and def.growthRate or 0, level) MonOps.recalc(data, mon, gen) end function MonOps.setMove(data, mon, slot, moveId) assert(slot >= 1 and slot <= 4) - local mdef = data.moves[moveId] - assert(type(mdef) == "table", "unknown move") + local mdef = data and data.moves and data.moves[moveId] + local numMove = tonumber(moveId) + if not mdef and numMove then + local okP, PokemonG3 = pcall(require, "src.core.game3.pokemon") + if okP and PokemonG3 then + local mName = PokemonG3.moveName(numMove) + local bMove = PokemonG3.battleMove(numMove) + mdef = { + id = mName, + moveId = numMove, + name = mName, + pp = (bMove and tonumber(bMove.pp)) or 10, + maxPp = (bMove and tonumber(bMove.pp)) or 10, + } + end + end + assert(type(mdef) == "table", "unknown move: " .. tostring(moveId)) mon.moves = mon.moves or {} + local basePp = mdef.pp or 10 + local currentUps = (mon.moves[slot] and mon.moves[slot].ppUps) or 0 mon.moves[slot] = { - id = moveId, - pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5), - ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil, - maxPp = mdef.pp, + id = mdef.id or mdef.name or tostring(moveId), + moveId = mdef.moveId or numMove, + pp = basePp + currentUps * math.floor(basePp / 5), + ppUps = currentUps > 0 and currentUps or nil, + maxPp = basePp, } end -- HP DV is derived from the low bits of the other four (Stats.randomDVs). function MonOps.syncHpDv(dvs) + if not dvs then return end dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + (dvs.speed % 2) * 2 + (dvs.special % 2) return dvs end function MonOps.setDv(data, mon, key, value, gen) + if type(mon) ~= "table" then return end + mon.dvs = mon.dvs or { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 } mon.dvs[key] = math.max(0, math.min(15, math.floor(value))) if key ~= "hp" then MonOps.syncHpDv(mon.dvs) end - if gen == 2 or (mon.stats and mon.stats.specialAttack) then + + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) or (mon.ivs ~= nil) + if isG3 then + mon.ivs = mon.ivs or {} + mon.ivs.hp = math.min(31, (mon.dvs.hp or 0) * 2 + 1) + mon.ivs.atk = math.min(31, (mon.dvs.attack or 0) * 2 + 1) + mon.ivs.def = math.min(31, (mon.dvs.defense or 0) * 2 + 1) + mon.ivs.spe = math.min(31, (mon.dvs.speed or 0) * 2 + 1) + mon.ivs.spa = math.min(31, (mon.dvs.special or 0) * 2 + 1) + mon.ivs.spd = math.min(31, (mon.dvs.special or 0) * 2 + 1) + elseif gen == 2 or (mon.stats and mon.stats.specialAttack) then local Mon = require("src.battle.gen2.Mon") mon.dvs.hp = Mon.hpDV(mon.dvs) - local def = data.pokemon[mon.species] + local def = data and data.pokemon and data.pokemon[mon.species] if def then mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level }) mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level }) @@ -81,12 +225,23 @@ function MonOps.setDv(data, mon, key, value, gen) MonOps.recalc(data, mon, gen) end --- Keep level; resync exp to the species growth curve (species changes). --- Gold also copies def.name onto mon.name: the party list and SUMMARY print --- that field when nickname is nil, so leaving the previous species' name --- made a swapped mon still read as ABRA (or whoever was added first). function MonOps.setSpecies(data, mon, species, gen) - assert(data.pokemon[species], "unknown species") + if type(mon) ~= "table" then return end + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local PokemonG3 = require("src.core.game3.pokemon") + pcall(PokemonG3.install, nil) + local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1 + local name = (PokemonG3.name and PokemonG3.name(spId)) or tostring(species) + mon.species = name + mon.speciesId = spId + mon.name = name + local meta = PokemonG3.speciesMeta and PokemonG3.speciesMeta(spId) + mon.growthRate = (meta and tonumber(meta.growthRate)) or 0 + MonOps.setLevel(data, mon, mon.level or 5, gen) + return + end + assert(data.pokemon[species], "unknown species: " .. tostring(species)) mon.species = species MonOps.setLevel(data, mon, mon.level, gen) if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 7960b639..85a535fc 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -37,11 +37,14 @@ end Ops.clamp = clamp local function stampNewMon(S, mon) - if Gen.ofState(S) == 2 then + if Gen.ofState(S) == 3 then + mon.ot = (S.save.player and S.save.player.name) or S.save.name or "RED" + mon.otId = (S.save.player and S.save.player.id) or S.save.trainerId or 0 + elseif Gen.ofState(S) == 2 then require("src.battle.gen2.Mon").stampOT(S.save, mon) else - mon.ot = S.save.player.name - mon.otId = S.save.player.id + mon.ot = S.save.player and S.save.player.name or "RED" + mon.otId = S.save.player and S.save.player.id or 0 end return mon end @@ -248,6 +251,16 @@ local function baseStatsComplete(bs, keys) end function Ops.speciesUsable(S, id) + if Gen.ofState(S) == 3 then + local def = id and S.data and S.data.pokemon and (S.data.pokemon[id] or (type(id) == "number" and S.data.pokemon[id])) + if type(def) == "table" and (def.speciesId or def.species or def.name) then return true end + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon then + local spId = tonumber(id) or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(id))) + if spId and Pokemon.name(spId) then return true end + end + return false + end local def = id and S.data.pokemon[id] if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1) @@ -929,13 +942,66 @@ function Ops.maxCoins(S) return Ops.addCoins(S, Ops.COIN_MAX) end +local function syncG3Bag(S) + if Gen.ofState(S) ~= 3 or not S.save then return end + local okB, BagG3 = pcall(require, "src.core.game3.bag") + if okB and BagG3 then + S.save.bag = BagG3.new() + for id, qty in pairs(S.save.inventory or {}) do + if qty and qty > 0 then + BagG3.add(S.save.bag, id, qty) + end + end + end +end + +local function itemQty(inv, id) + if not inv or id == nil then return 0 end + local val = inv[id] + if type(val) == "number" then return val end + if type(val) == "table" then + return tonumber(val.qty or val.quantity or val.count or val[2]) or 0 + end + if val == true then return 1 end + return tonumber(val) or 0 +end +Ops.itemQty = itemQty + +local function syncG3Pc(S) + if Gen.ofState(S) ~= 3 or not S.save then return end + S.save.pc = S.save.pc or { items = {} } + local okD, ItemsData = pcall(require, "src.core.game3.items_data") + local items = {} + for id, val in pairs(S.save.pcItems or {}) do + local qty = itemQty(S.save.pcItems, id) + if qty and qty > 0 then + local num = okD and ItemsData and ItemsData.toNumericId(id) or id + items[#items + 1] = { id = num, qty = qty } + end + end + S.save.pc.items = items +end + function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end + S.save.inventory = S.save.inventory or {} + if Gen.ofState(S) == 3 then + local BagG3 = require("src.core.game3.bag") + S.save.bag = S.save.bag or BagG3.new() + local ok = BagG3.add(S.save.bag, id, 1) + if ok then + S.save.inventory[id] = itemQty(S.save.inventory, id) + 1 + Bag.order(S.save, S.data) + return Ops.mark(S, ("Added %s to the bag"):format(tostring(id))) + else + return Ops.say(S, ("Could not add %s to bag (pocket full)"):format(tostring(id))) + end + end local pocket = Bag.pocketOf(id, S.data) local capacity = Bag.capacity(S.data, pocket) if Bag.add(S.save, id, 1, S.data) then return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)") - :format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket)) + :format(tostring(id), Bag.slots(S.save, S.data, pocket), capacity, pocket)) end return Ops.say(S, ("Bag is full (%d/%d %s slots)") :format(Bag.slots(S.save, S.data, pocket), capacity, pocket)) @@ -943,26 +1009,56 @@ end function Ops.bagAdjust(S, id, delta) if not id then return Ops.say(S, "No bag row selected") end + S.save.inventory = S.save.inventory or {} + local have = itemQty(S.save.inventory, id) + if Gen.ofState(S) == 3 then + if delta > 0 then + if have >= Ops.STACK_MAX then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) + end + local nextQty = have + delta + S.save.inventory[id] = nextQty + syncG3Bag(S) + else + local nextQty = have + delta + if nextQty <= 0 then + S.save.inventory[id] = nil + Bag.order(S.save, S.data) + syncG3Bag(S) + return Ops.mark(S, ("Removed the last %s from the bag"):format(tostring(id))) + else + S.save.inventory[id] = nextQty + syncG3Bag(S) + end + end + return Ops.mark(S, ("%s x%d"):format(tostring(id), itemQty(S.save.inventory, id))) + end if delta > 0 then - local have = S.save.inventory[id] or 0 if have >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) end Bag.add(S.save, id, delta, S.data) else Bag.remove(S.save, id, -delta) if not S.save.inventory[id] then - return Ops.mark(S, ("Removed the last %s from the bag"):format(id)) + return Ops.mark(S, ("Removed the last %s from the bag"):format(tostring(id))) end end - return Ops.mark(S, ("%s x%d"):format(id, S.save.inventory[id] or 0)) + return Ops.mark(S, ("%s x%d"):format(tostring(id), itemQty(S.save.inventory, id))) end function Ops.bagDrop(S, id) if not id then return Ops.say(S, "No bag row selected") end - local qty = S.save.inventory[id] or 0 + S.save.inventory = S.save.inventory or {} + local qty = itemQty(S.save.inventory, id) + if Gen.ofState(S) == 3 then + S.save.inventory[id] = nil + Bag.order(S.save, S.data) + syncG3Bag(S) + return Ops.mark(S, ("Dropped all %d %s"):format(qty, tostring(id))) + end Bag.remove(S.save, id, qty) - return Ops.mark(S, ("Dropped all %d %s"):format(qty, id)) + return Ops.mark(S, ("Dropped all %d %s"):format(qty, tostring(id))) end -- home/list_menu.asm:474 IsKeyItem (Gen 1 prints no count for key items or @@ -970,6 +1066,10 @@ end -- and engine/items/tmhm.asm:390 prints no count for an HM. function Ops.itemStacks(S, id) if not id then return false end + if Gen.ofState(S) == 3 then + local def = S.data and S.data.items and S.data.items[id] + return (def and def.pocket) ~= "KEY_ITEMS" and not tostring(id):find("^HM_") + end if Gen.ofState(S) == 2 then return Bag.pocketOf(id, S.data) ~= "KEY_ITEM" and tostring(id):sub(1, 3) ~= "HM_" @@ -981,21 +1081,27 @@ end -- engine/items/inventory.asm:74 caps a slot at 99 function Ops.bagMax(S, id) if not id then return Ops.say(S, "No bag row selected") end - local have = S.save.inventory[id] or 0 - if have <= 0 then return Ops.say(S, ("%s is not in the bag"):format(id)) end + S.save.inventory = S.save.inventory or {} + local have = itemQty(S.save.inventory, id) + if have <= 0 then return Ops.say(S, ("%s is not in the bag"):format(tostring(id))) end if not Ops.itemStacks(S, id) then - return Ops.say(S, ("%s has no quantity to max"):format(id)) + return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end if have >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) end - Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) - return Ops.mark(S, ("%s x%d"):format(id, Ops.STACK_MAX)) + if Gen.ofState(S) == 3 then + S.save.inventory[id] = Ops.STACK_MAX + syncG3Bag(S) + else + Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) + end + return Ops.mark(S, ("%s x%d"):format(tostring(id), Ops.STACK_MAX)) end function Ops.bagCanMax(S, id) - if id then - local have = S.save.inventory[id] or 0 + if id ~= nil then + local have = itemQty(S.save.inventory, id) return have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) end for _, rowId in ipairs(Bag.order(S.save, S.data)) do @@ -1010,24 +1116,29 @@ function Ops.bagMaxAll(S) for i = 1, #order do ids[i] = order[i] end local n = 0 for _, id in ipairs(ids) do - local have = S.save.inventory[id] or 0 + local have = itemQty(S.save.inventory, id) if have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) then - Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) + if Gen.ofState(S) == 3 then + S.save.inventory[id] = Ops.STACK_MAX + else + Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) + end n = n + 1 end end if n == 0 then return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.STACK_MAX)) end + if Gen.ofState(S) == 3 then syncG3Bag(S) end return Ops.mark(S, ("Maxed %d bag stack%s to x%d") :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) end -- constants/item_data_constants.asm:41 -local POCKET_RANK = { ITEM = 1, BALL = 2, KEY_ITEM = 3, TM_HM = 4 } +local POCKET_RANK = { ITEM = 1, ITEMS = 1, BALL = 2, POKE_BALLS = 2, KEY_ITEM = 3, KEY_ITEMS = 3, TM_HM = 4, TM_CASE = 4, BERRY_POUCH = 5 } local ITEM_SORT_KEYS = { - index = function(def, id) return (def and def.index) or math.huge end, + index = function(def, id) return (def and (def.itemId or def.index)) or math.huge end, name = function(def, id) return tostring((def and def.name) or id):lower() end, @@ -1036,18 +1147,23 @@ local ITEM_SORT_KEYS = { local function itemRows(S, ids, mode) local make = ITEM_SORT_KEYS[mode] or ITEM_SORT_KEYS.index local items = S.data and S.data.items - local gen2 = Gen.ofState(S) == 2 + local isPocketGen = Gen.ofState(S) >= 2 local rows = {} for i = 1, #ids do local id = ids[i] local def = items and items[id] + local pName = (def and def.pocket) or Bag.pocketOf(id, S.data) or "ITEM" rows[i] = { id = id, key = make(def, id), - rank = gen2 and (POCKET_RANK[Bag.pocketOf(id, S.data)] or 9) or 0 } + rank = isPocketGen and (POCKET_RANK[pName] or 9) or 0 } end table.sort(rows, function(a, b) if a.rank ~= b.rank then return a.rank < b.rank end - if a.key ~= b.key then return a.key < b.key end - return a.id < b.id + if a.key ~= b.key then + if type(a.key) == type(b.key) then return a.key < b.key end + return tostring(a.key) < tostring(b.key) + end + if type(a.id) == type(b.id) then return a.id < b.id end + return tostring(a.id) < tostring(b.id) end) local out = {} for i = 1, #rows do out[i] = rows[i].id end @@ -1105,54 +1221,64 @@ function Ops.addToPc(S, id) if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then return Ops.say(S, "PC item storage is full (50 stacks)") end - local pc = Ops.pcItems(S) - pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1) - return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id])) + local cur = itemQty(pc, id) + pc[id] = math.min(Ops.STACK_MAX, cur + 1) + syncG3Pc(S) + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end function Ops.pcAdjust(S, id, delta) if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) - if not pc[id] then return Ops.say(S, ("%s is not in PC storage"):format(id)) end - if delta > 0 and pc[id] >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + local cur = itemQty(pc, id) + if not pc[id] and cur <= 0 then return Ops.say(S, ("%s is not in PC storage"):format(tostring(id))) end + if delta > 0 and cur >= Ops.STACK_MAX then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) end - pc[id] = clamp(pc[id] + delta, 0, Ops.STACK_MAX) - if pc[id] <= 0 then + local nextQty = clamp(cur + delta, 0, Ops.STACK_MAX) + if nextQty <= 0 then pc[id] = nil - return Ops.mark(S, ("Removed %s from PC storage"):format(id)) + syncG3Pc(S) + return Ops.mark(S, ("Removed %s from PC storage"):format(tostring(id))) end - return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id])) + pc[id] = nextQty + syncG3Pc(S) + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end function Ops.pcDrop(S, id) if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) - local qty = pc[id] or 0 + local qty = itemQty(pc, id) pc[id] = nil - return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id)) + syncG3Pc(S) + return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, tostring(id))) end function Ops.pcMax(S, id) if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) - if not pc[id] then return Ops.say(S, ("%s is not in PC storage"):format(id)) end + local cur = itemQty(pc, id) + if not pc[id] and cur <= 0 then return Ops.say(S, ("%s is not in PC storage"):format(tostring(id))) end if not Ops.itemStacks(S, id) then - return Ops.say(S, ("%s has no quantity to max"):format(id)) + return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end - if pc[id] >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + if cur >= Ops.STACK_MAX then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) end pc[id] = Ops.STACK_MAX - return Ops.mark(S, ("%s x%d in PC storage"):format(id, Ops.STACK_MAX)) + syncG3Pc(S) + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), Ops.STACK_MAX)) end function Ops.pcCanMax(S, id) local pc = Ops.pcItems(S) - if id then - return (pc[id] or 0) > 0 and pc[id] < Ops.STACK_MAX and Ops.itemStacks(S, id) + if id ~= nil then + local have = itemQty(pc, id) + return have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) end - for rowId, qty in pairs(pc) do + for rowId, val in pairs(pc) do + local qty = itemQty(pc, rowId) if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, rowId) then return true end @@ -1163,8 +1289,8 @@ end function Ops.pcMaxAll(S) local pc = Ops.pcItems(S) local n = 0 - for _, id in ipairs(Ops.pcOrder(S)) do - local qty = pc[id] or 0 + for id, val in pairs(pc) do + local qty = itemQty(pc, id) if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, id) then pc[id] = Ops.STACK_MAX n = n + 1 @@ -1173,6 +1299,7 @@ function Ops.pcMaxAll(S) if n == 0 then return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.STACK_MAX)) end + syncG3Pc(S) return Ops.mark(S, ("Maxed %d PC stack%s to x%d") :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) end @@ -1180,6 +1307,7 @@ end -- Badges are truthy inventory flags, not stackable items, which is why the -- design gives them toggle chips instead of quantity rows. function Ops.isBadgeId(id) + if type(id) ~= "string" then return false end return id:find("BADGE", 1, true) ~= nil end @@ -1360,6 +1488,12 @@ end -- has already visited. function Ops.isOutdoor(S, map) if not map or not map.def then return false end + if Gen.ofState(S) == 3 then + local env = map.def.environment + if env == "TOWN" or env == "ROUTE" or env == "OVERWORLD" then return true end + if map.def.connections and next(map.def.connections) ~= nil then return true end + return false + end local Map2 = require("src.world.gen2.Map") if Gen.ofState(S) == 2 and Map2.isOutdoor then return Map2.isOutdoor(map.def) and true or false @@ -1381,7 +1515,12 @@ end function Ops.setLastOutdoor(S, map) local cell = S.mapClickCell if not cell then return Ops.say(S, "Click a cell first") end - if Gen.ofState(S) == 2 then + if Gen.ofState(S) == 3 then + S.save.healMap = S.mapId + S.save.healX = cell.cx + S.save.healY = cell.cy + return Ops.mark(S, ("heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) + elseif Gen.ofState(S) == 2 then S.save.spawn = S.mapId return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) end @@ -1395,7 +1534,12 @@ end function Ops.setLastHeal(S) local cell = S.mapClickCell if not cell then return Ops.say(S, "Click a cell first") end - if Gen.ofState(S) == 2 then + if Gen.ofState(S) == 3 then + S.save.healMap = S.mapId + S.save.healX = cell.cx + S.save.healY = cell.cy + return Ops.mark(S, ("heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) + elseif Gen.ofState(S) == 2 then S.save.spawn = S.mapId return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) end diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index dddd7510..2ec71574 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -141,8 +141,13 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) Kit.text("micro", tostring(i), bx + 10 * s, by + 8 * s, PAL.faint) Kit.textRight("micro", ("Lv%d"):format(mon.level), bx + cellW - 10 * s, by + 8 * s, PAL.caption) + local monName = (mon.nickname and mon.nickname ~= "" and mon.nickname) + or (type(mon.species) == "string" and mon.species) + or (type(mon.name) == "string" and mon.name ~= "" and mon.name) + or (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] and S.data.pokemon[mon.species or mon.speciesId].name) + or tostring(mon.species or "POKEMON") Kit.textCenter("mono", - Kit.ellipsize("mono", mon.species, cellW - 12 * s), bx, + Kit.ellipsize("mono", monName, cellW - 12 * s), bx, by + cellH / 2 - Kit.textHeight("mono") / 2, cellW, PAL.text) else -- empty slots are dashed and clickable: clicking one opens the species @@ -200,7 +205,12 @@ local function drawDock(S, Kit, dx, y, dockW, h) local lvW = Kit.textWidth("tiny", lv) Kit.textRight("tiny", lv, dx + pad + dInner - 10 * s, ry + (dRowH - Kit.textHeight("tiny")) / 2, PAL.caption) - Kit.text("mono", Kit.ellipsize("mono", mon.species, dInner - 30 * s - lvW), + local monName = (mon.nickname and mon.nickname ~= "" and mon.nickname) + or (type(mon.species) == "string" and mon.species) + or (type(mon.name) == "string" and mon.name ~= "" and mon.name) + or (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] and S.data.pokemon[mon.species or mon.speciesId].name) + or tostring(mon.species or "POKEMON") + Kit.text("mono", Kit.ellipsize("mono", monName, dInner - 30 * s - lvW), dx + pad + 10 * s, ry + (dRowH - Kit.textHeight("mono")) / 2, PAL.text) end Kit.popClip() diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index e2cf5687..2afb143e 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -48,13 +48,18 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu { kind = "danger", font = "tiny", radius = 6 * s }) then onDrop() end - local qtyText = ("x%d"):format(qty) + local qtyText = ("x%d"):format(tonumber(qty) or 0) local qtyW = Kit.textWidth("monoRow", qtyText) Kit.textRight("monoRow", qtyText, bx - 10 * s, y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading) - local label = id - if Gen.of(S.save) == 2 then - label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id + local label = tostring(id) + if Gen.of(S.save) == 3 then + local def = S.data and S.data.items and S.data.items[id] + local pName = (def and def.pocket) or "ITEM" + local name = (def and (def.name or def.id)) or tostring(id) + label = pName .. " " .. name + elseif Gen.of(S.save) == 2 then + label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. tostring(id) end Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)), x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text) @@ -134,8 +139,8 @@ local function drawTrainer(S, Kit, x, y, w, h) local pad = 16 * s Kit.card(x, y, w, h) Kit.caption(x + pad, y + pad, "TRAINER") - Kit.textRight("mono", tostring((S.save.player and S.save.player.name) or "?"), - x + w - pad, y + pad, PAL.caption) + local trainerName = (S.save.player and S.save.player.name) or S.save.name or S.save.playerName or "?" + Kit.textRight("mono", tostring(trainerName), x + w - pad, y + pad, PAL.caption) local cy = y + pad + Kit.textHeight("caption") + 10 * s local chipW = (w - 2 * pad - 8 * s) / 2 local gender = Gen.playerGender(S.save) @@ -218,15 +223,15 @@ local function drawQuantityCard(S, Kit, x, y, w, h, cfg) local s = Kit.scale local pad = 16 * s Kit.card(x, y, w, h) - Kit.caption(x + pad, y + pad, cfg.title) + Kit.caption(x + pad, y + pad, tostring(cfg.title or "")) local hx = x + w - pad local hy = y + pad - 4 * s local maxW = Kit.textWidth("tiny", "Max all") + 22 * s hx = hx - maxW if Kit.button(hx, hy, maxW, 24 * s, "Max all", { kind = "accent", font = "tiny", radius = 7 * s, - enabled = cfg.canMaxAll() }) then - cfg.maxAll() + enabled = cfg.canMaxAll and cfg.canMaxAll() }) then + if cfg.maxAll then cfg.maxAll() end end for i = #SORT_MODES, 1, -1 do local mode = SORT_MODES[i] @@ -234,18 +239,19 @@ local function drawQuantityCard(S, Kit, x, y, w, h, cfg) hx = hx - 6 * s - bw if Kit.button(hx, hy, bw, 24 * s, mode[1], { kind = "ghost", font = "tiny", radius = 6 * s }) then - cfg.sort(mode[2]) + if cfg.sort then cfg.sort(mode[2]) end end end + local counterStr = tostring(cfg.counter or "") local counterX = hx - 8 * s - if counterX - Kit.textWidth("mono", cfg.counter) - >= x + pad + Kit.captionWidth(cfg.title) + 8 * s then - Kit.textRight("mono", cfg.counter, counterX, y + pad, PAL.caption) + if counterX - Kit.textWidth("mono", counterStr) + >= x + pad + Kit.captionWidth(tostring(cfg.title or "")) + 8 * s then + Kit.textRight("mono", counterStr, counterX, y + pad, PAL.caption) end local rowsTop = y + pad + Kit.textHeight("caption") + 8 * s if cfg.meterFrac then - Kit.meter(x + pad, rowsTop, w - 2 * pad, 5 * s, cfg.meterFrac * 100, - cfg.meterFrac >= 1 and PAL.yellow or PAL.blue) + Kit.meter(x + pad, rowsTop, w - 2 * pad, 5 * s, (tonumber(cfg.meterFrac) or 0) * 100, + (tonumber(cfg.meterFrac) or 0) >= 1 and PAL.yellow or PAL.blue) rowsTop = rowsTop + 5 * s + 12 * s else rowsTop = rowsTop + 12 * s @@ -255,49 +261,57 @@ local function drawQuantityCard(S, Kit, x, y, w, h, cfg) local pagerY = y + h - pad - pagerH local rowH = 36 * s local rowGap = 6 * s - local listH = pagerY - 12 * s - rowsTop + local listH = math.max(0, pagerY - 12 * s - rowsTop) local perPage = math.max(1, math.floor(listH / (rowH + rowGap))) - local order = cfg.order + local order = cfg.order or {} local offset = Ops.clamp(cfg.offset or 0, 0, math.max(0, #order - perPage)) -- the wheel moves the same offset the pager below does (#595) - offset = Kit.scroll(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) + if listH > 0 then + offset = Kit.scroll(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) + end - if #order == 0 then - Kit.emptyBox(x + pad, rowsTop, w - 2 * pad, math.min(listH, 70 * s), cfg.empty) + if #order == 0 and listH > 0 then + Kit.emptyBox(x + pad, rowsTop, w - 2 * pad, math.min(listH, 70 * s), cfg.empty or "") end - Kit.pushClip(x + pad, rowsTop, w - 2 * pad, listH) - for i = 1, math.min(perPage, #order - offset) do - local id = order[offset + i] - local ry = rowsTop + (i - 1) * (rowH + rowGap) - if quantityRow(S, Kit, x + pad, ry, w - 2 * pad, rowH, id, - cfg.qty(id), id == cfg.selected(), - function() cfg.adjust(id, -1) end, - function() cfg.adjust(id, 1) end, - function() cfg.max(id) end, cfg.canMax(id), - function() cfg.drop(id) end) then - cfg.select(id) + if listH > 0 then + Kit.pushClip(x + pad, rowsTop, w - 2 * pad, listH) + for i = 1, math.min(perPage, #order - offset) do + local id = order[offset + i] + local ry = rowsTop + (i - 1) * (rowH + rowGap) + if id ~= nil and quantityRow(S, Kit, x + pad, ry, w - 2 * pad, rowH, id, + cfg.qty and cfg.qty(id) or 0, id == (cfg.selected and cfg.selected()), + function() if cfg.adjust then cfg.adjust(id, -1) end end, + function() if cfg.adjust then cfg.adjust(id, 1) end end, + function() if cfg.max then cfg.max(id) end end, cfg.canMax and cfg.canMax(id), + function() if cfg.drop then cfg.drop(id) end end) then + if cfg.select then cfg.select(id) end + end end + Kit.popClip() + end + if listH > 0 then + Kit.scrollbar(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) end - Kit.popClip() - Kit.scrollbar(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) return Kit.pager(x + pad, pagerY, w - 2 * pad, offset, #order, perPage) end local function drawBag(S, Kit, x, y, w, h) - local order = Bag.order(S.save) - local capacity = Bag.capacity(S.data) + S.save.inventory = S.save.inventory or {} + local order = Bag.order(S.save, S.data) or {} + local capacity = Bag.capacity(S.data) or 20 + local slots = Bag.slots(S.save, S.data) or 0 S.bagOffset = drawQuantityCard(S, Kit, x, y, w, h, { title = "BAG", - counter = ("%d/%d slots"):format(Bag.slots(S.save), capacity), - meterFrac = Bag.slots(S.save) / capacity, + counter = ("%d/%d slots"):format(slots, capacity), + meterFrac = slots / math.max(1, capacity), order = order, - offset = S.bagOffset, + offset = S.bagOffset or 0, empty = "Bag is empty.", - qty = function(id) return S.save.inventory[id] or 0 end, + qty = function(id) return (S.save.inventory and S.save.inventory[id]) or 0 end, selected = function() return S.selectedBagId end, select = function(id) S.selectedBagId = id - Ops.say(S, ("Selected %s in the bag"):format(id)) + Ops.say(S, ("Selected %s in the bag"):format(tostring(id))) end, adjust = function(id, d) Ops.bagAdjust(S, id, d) end, max = function(id) Ops.bagMax(S, id) end, @@ -310,18 +324,18 @@ local function drawBag(S, Kit, x, y, w, h) end local function drawPc(S, Kit, x, y, w, h) - local pcOrder = Ops.pcOrder(S) + local pcOrder = Ops.pcOrder(S) or {} S.pcOffset = drawQuantityCard(S, Kit, x, y, w, h, { title = "PC STORAGE", counter = ("%d kinds"):format(#pcOrder), order = pcOrder, - offset = S.pcOffset, + offset = S.pcOffset or 0, empty = "PC storage is empty. Items sent here have no slot cap.", - qty = function(id) return S.save.pcItems[id] or 0 end, + qty = function(id) return (S.save.pcItems and S.save.pcItems[id]) or 0 end, selected = function() return S.selectedPcId end, select = function(id) S.selectedPcId = id - Ops.say(S, ("Selected %s in PC storage"):format(id)) + Ops.say(S, ("Selected %s in PC storage"):format(tostring(id))) end, adjust = function(id, d) Ops.pcAdjust(S, id, d) end, max = function(id) Ops.pcMax(S, id) end, diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index dd18d4cd..468abc55 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -58,7 +58,7 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true } local function goToWarp(S, warp) local def = warp.def - if Gen.of(S.save) == 2 then + if Gen.of(S.save) == 3 or Gen.of(S.save) == 2 then local dest = def.destMap or def.map if dest then S.mapId = dest @@ -151,7 +151,13 @@ local function drawOverlays(S, map) love.graphics.setColor(1, 0.36, 0.4, 0.9) love.graphics.rectangle("fill", cellRect(px, py)) end - if Gen.of(S.save) ~= 2 then + if Gen.of(S.save) == 3 then + local healMap = S.save.healMap + if healMap == S.mapId and S.save.healX and S.save.healY then + love.graphics.setColor(0.24, 0.88, 0.54, 0.9) + love.graphics.rectangle("line", cellRect(S.save.healX, S.save.healY)) + end + elseif Gen.of(S.save) ~= 2 then local heal = S.save.lastHeal if heal and heal.map == S.mapId then love.graphics.setColor(0.24, 0.88, 0.54, 0.9) @@ -279,7 +285,82 @@ function MapBrowser.draw(S, Kit, x, y, w, h) vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading) local ok, map - if Gen.of(S.save) == 2 then + if Gen.of(S.save) == 3 then + local def = Gen.maps(S.data)[S.mapId] + if def then + local mw = def.width or 20 + local mh = def.height or 18 + local midLayout = def.midLayout + local pair = def.pair or (midLayout and midLayout.pair) + map = { + id = S.mapId, + def = def, + width = mw, + height = mh, + widthCells = mw, + heightCells = mh, + warps = def.warps or {}, + inBounds = function(self, cx, cy) + return cx >= 0 and cx < self.width and cy >= 0 and cy < self.height + end, + warpAtCell = function(self, cx, cy) + for _, w in ipairs(self.warps or {}) do + if w.x == cx and w.y == cy then + return { def = w } + end + end + return nil + end, + tileAtCell = function(self, cx, cy) + if midLayout and midLayout.midAt then + return midLayout:midAt(cx, cy) or 0 + end + return 0 + end, + } + + local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native") + if okN and NativeTileset and pair and midLayout and midLayout.midAt then + local ts = NativeTileset.get(pair) + if ts and ts.image then + map.renderer = { + draw = function(self, camX, camY) + local viewW = S._mapViewW or 480 + local viewH = S._mapViewH or 432 + local zoom = S.mapZoom or 1 + local startCx = math.max(0, math.floor(camX / CELL) - 1) + local endCx = math.min(mw - 1, math.ceil((camX + viewW / zoom) / CELL) + 1) + local startCy = math.max(0, math.floor(camY / CELL) - 1) + local endCy = math.min(mh - 1, math.ceil((camY + viewH / zoom) / CELL) + 1) + + love.graphics.setColor(1, 1, 1, 1) + for cy = startCy, endCy do + for cx = startCx, endCx do + local mid = midLayout:midAt(cx, cy) + if mid and mid >= 0 then + local slot = NativeTileset.slotFor(ts, mid) + local q = NativeTileset.quad(ts, slot) + if q then + love.graphics.draw(ts.image, q, cx * CELL - camX, cy * CELL - camY) + end + if ts.layered and ts.overImage then + local oq = NativeTileset.overQuad(ts, slot) + if oq then + love.graphics.draw(ts.overImage, oq, cx * CELL - camX, cy * CELL - camY) + end + end + end + end + end + end + } + end + end + ok = true + else + ok, map = false, "unknown map" + end + elseif Gen.of(S.save) == 2 then local def = Gen.maps(S.data)[S.mapId] if def then local Map2 = require("src.world.gen2.Map") @@ -474,7 +555,18 @@ function MapBrowser.draw(S, Kit, x, y, w, h) local pmap2, px2, py2 = playerPos(S) local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset" local spawns - if Gen.of(S.save) == 2 then + if Gen.of(S.save) == 3 then + local healMap = S.save.healMap + local healX = S.save.healX or 0 + local healY = S.save.healY or 0 + spawns = { + { key = "PLAYER", color = PAL.red, value = playerValue, + set = function() Ops.setPlayerHere(S) end }, + { key = "LAST HEAL", color = PAL.green, + value = healMap and ("%s (%d,%d)"):format(healMap, healX, healY) or "unset", + set = function() Ops.setLastHeal(S) end }, + } + elseif Gen.of(S.save) == 2 then spawns = { { key = "PLAYER", color = PAL.red, value = playerValue, set = function() Ops.setPlayerHere(S) end }, diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 4b4df13a..855c11a0 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -45,8 +45,22 @@ local STAT_KEYS_G2 = { -- which re-decoded a PNG sixty times a second. local spriteCache = {} function MonEditor.sprite(S, species) + if not species then return nil end if spriteCache[species] ~= nil then return spriteCache[species] or nil end - local def = S.data.pokemon[species] + if Gen.ofState(S) == 3 then + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon then + local spId = tonumber(species) or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(species))) + if spId then + local pic = (Pokemon.frontPic and Pokemon.frontPic(spId)) or (Pokemon.icon and Pokemon.icon(spId)) + if pic and pic.image then + spriteCache[species] = pic.image + return pic.image + end + end + end + end + local def = S.data and S.data.pokemon and (S.data.pokemon[species] or (type(species) == "number" and S.data.pokemon[species])) local path = def and def.spriteFront if not path or not love.graphics.newImage then spriteCache[species] = false @@ -75,7 +89,7 @@ function MonEditor.drawSprite(S, Kit, species, x, y, size) love.graphics.rectangle("fill", x, y, size, size, 8 * Kit.scale, 8 * Kit.scale) Theme.col(PAL.cardBorder, 0.35) Theme.dashed(x, y, size, size, 8 * Kit.scale, 5 * Kit.scale, 4 * Kit.scale) - Kit.textCenter("micro", (species or "?"):sub(1, 3), x, + Kit.textCenter("micro", tostring(species or "?"):sub(1, 3), x, y + size / 2 - Kit.textHeight("micro") / 2, size, PAL.muted) end @@ -120,10 +134,11 @@ end local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) local s = Kit.scale + mon.dvs = mon.dvs or { attack = 0, defense = 0, speed = 0, special = 0, hp = 0 } for i, key in ipairs(DV_KEYS) do local ry = rowY + (i - 1) * (rowH + rowGap) Theme.row(cx, ry, colW, rowH, 10 * s, 0.6) - local v = mon.dvs[key] or 0 + local v = (mon.dvs and mon.dvs[key]) or 0 Kit.text("tiny", key:upper(), cx + 10 * s, ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.muted) local btn = 26 * s @@ -215,13 +230,24 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local mv = mon.moves and mon.moves[slot] local clear = Kit.tapMin() local clearX = rightX + colW - 10 * s - clear - local ppText = mv and ("PP %d"):format(mv.pp or 0) or "" + local mvId = nil + local mvPp = 0 + if type(mv) == "table" then + mvId = mv.id or mv.name or (mv.moveId and (S.data and S.data.moves and S.data.moves[mv.moveId] and S.data.moves[mv.moveId].name)) + mvPp = mv.pp or 0 + elseif type(mv) == "number" then + mvId = S.data and S.data.moves and S.data.moves[mv] and S.data.moves[mv].name + mvPp = S.data and S.data.moves and S.data.moves[mv] and S.data.moves[mv].pp or 10 + elseif type(mv) == "string" then + mvId = mv + end + local ppText = mv and ("PP %d"):format(mvPp) or "" local ppW = Kit.textWidth("tiny", ppText) Kit.text("mono", tostring(slot), rightX + 10 * s, ry + (rowH - Kit.textHeight("mono")) / 2, PAL.faint) local nameX = rightX + 28 * s local nameW2 = math.max(20 * s, clearX - 12 * s - ppW - nameX) - Kit.text("monoRow", Kit.ellipsize("monoRow", mv and mv.id or "-- --", nameW2), + Kit.text("monoRow", Kit.ellipsize("monoRow", mvId or "-- --", nameW2), nameX, ry + (rowH - Kit.textHeight("monoRow")) / 2, mv and PAL.text or PAL.faint) Kit.textRight("tiny", ppText, clearX - 10 * s, @@ -253,7 +279,8 @@ function MonEditor.draw(S, Kit, x, y, w, h) return end - local def = S.data.pokemon[mon.species] + mon.dvs = mon.dvs or { attack = 0, defense = 0, speed = 0, special = 0, hp = 0 } + local def = (S.data and S.data.pokemon and (S.data.pokemon[mon.species] or S.data.pokemon[mon.speciesId])) or {} local inner = w - 2 * pad local capH = Kit.textHeight("caption") local titleH = Kit.textHeight("title") @@ -307,7 +334,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local cy = y + pad - S.inspectorScroll -- ---------------------------------------------------------- header row - MonEditor.drawSprite(S, Kit, mon.species, cx, cy, sprite) + MonEditor.drawSprite(S, Kit, mon.species or mon.speciesId, cx, cy, sprite) local hx = cx + sprite + 18 * s -- One control instead of a pair of arrows: cycling walked the catalog an @@ -321,11 +348,12 @@ function MonEditor.draw(S, Kit, x, y, w, h) local py = cy + (titleH - pickH) / 2 -- the species name yields to the button instead of running under it (#715) - local name = Kit.ellipsize("title", mon.species, math.max(40 * s, px - hx - 12 * s)) + local spName = (type(mon.species) == "string" and mon.species) or def.name or tostring(mon.species or "POKEMON") + local name = Kit.ellipsize("title", spName, math.max(40 * s, px - hx - 12 * s)) Kit.text("title", name, hx, cy, PAL.heading) local nameW = Kit.textWidth("title", name) if nameW + Kit.textWidth("tiny", "#000") + 12 * s < px - hx - 12 * s then - Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s, + Kit.text("tiny", ("#%03d"):format(tonumber(def and def.dex) or (tonumber(mon.speciesId) or 0)), hx + nameW + 12 * s, cy + titleH - Kit.textHeight("tiny") - 2 * s, PAL.caption) end @@ -384,13 +412,13 @@ function MonEditor.draw(S, Kit, x, y, w, h) local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") statsY = statsY + capH + 10 * s - local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1 + local STAT_KEYS = Gen.ofState(S) >= 2 and STAT_KEYS_G2 or STAT_KEYS_G1 local gap = 12 * s local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS for i, st in ipairs(STAT_KEYS) do local bx = cx + (i - 1) * (cellW + gap) Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6) - local value = (mon.stats and mon.stats[st.field]) or 0 + local value = (mon.stats and (mon.stats[st.field] or mon.stats[st.field:lower()])) or mon[st.field] or 0 Kit.text("micro", st.key, bx + 12 * s, statsY + 8 * s, PAL.caption) Kit.text("stat", tostring(value), bx + 12 * s, statsY + 8 * s + Kit.textHeight("micro") + 2 * s, PAL.heading) @@ -442,7 +470,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- stacked: DVs first, then moves, then the two actions side by side at -- full width (#715) Kit.caption(cx, colY, "DVs") - Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), + Kit.textRight("tiny", ("HP DV auto-derived . %d"):format((mon.dvs and mon.dvs.hp) or 0), cx + inner, colY, PAL.caption) local rowY = colY + capH + 10 * s drawDvRows(S, Kit, mon, cx, rowY, inner, rowH, rowGap) @@ -469,7 +497,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local rightX = cx + colW + colGap Kit.caption(cx, colY, "DVs") - Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), + Kit.textRight("tiny", ("HP DV auto-derived . %d"):format((mon.dvs and mon.dvs.hp) or 0), cx + colW, colY, PAL.caption) Kit.caption(rightX, colY, "MOVES") Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption) diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 0a2e44c4..51368c43 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -106,7 +106,12 @@ local function drawRoster(S, Kit, x, y, listW, h) local tx = cx + rpad + icon + 12 * s local tw = math.max(40 * s, (cx + innerW - rightW - 10 * s) - tx) - local name = Kit.ellipsize("monoRow", mon.species, tw - 34 * s) + local monName = (mon.nickname and mon.nickname ~= "" and mon.nickname) + or (type(mon.species) == "string" and mon.species) + or (type(mon.name) == "string" and mon.name ~= "" and mon.name) + or (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] and S.data.pokemon[mon.species or mon.speciesId].name) + or tostring(mon.species or "POKEMON") + local name = Kit.ellipsize("monoRow", monName, tw - 34 * s) Kit.text("monoRow", name, tx, ry + 10 * s, PAL.heading) Kit.text("tiny", ("#%d"):format(slot), tx + Kit.textWidth("monoRow", name) + 8 * s, ry + 12 * s, PAL.caption)