diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 30d6b056..540febf2 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -105,6 +105,9 @@ function Game3:load(opts) local continueOk = self:_hasContinueSave() self.boot = Boot.new() Boot.setHasContinue(self.boot, continueOk) + if continueOk then + Boot.setContinueInfo(self.boot, Boot.continueInfoFromSave(rawSave)) + end self.phase = "boot" self.session = nil @@ -245,11 +248,11 @@ function Game3:draw() Boot.draw(self.boot) love.graphics.setCanvas() love.graphics.pop() - local scale, ox, oy = Display.fit(w, h) + local scale, ox, oy, _, _, scaleY = Display.fit(w, h) love.graphics.setColor(0.02, 0.04, 0.08, 1) love.graphics.rectangle("fill", 0, 0, w, h) love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, ox, oy, 0, scale, scale) + love.graphics.draw(canvas, ox, oy, 0, scale, scaleY) else Boot.draw(self.boot) end diff --git a/src/core/game3.lua b/src/core/game3.lua deleted file mode 100644 index 4b98a00d..00000000 --- a/src/core/game3.lua +++ /dev/null @@ -1,2 +0,0 @@ --- Package entry for Game3 runtime modules. -return require("src.core.game3.init") diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index aaf0ac82..a3aee8c3 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -16,14 +16,19 @@ local Healthbox = {} Healthbox.ENEMY_CENTER = { x = 44, y = 30 } Healthbox.PLAYER_CENTER = { x = 158, y = 88 } +local function c5(v) + return math.floor(v * 255 / 31 + 0.5) / 255 +end + -- Cream fill matches healthbox pal index 2 (text bg). -local CREAM = { 248 / 255, 248 / 255, 216 / 255, 1 } +-- pokefirered/src/battle_interface.c:2204 +local CREAM = { c5(31), c5(31), c5(27), 1 } -- Healthbox OBJ pal text colors (gBattleInterface_Healthbox_Pal). -- Nick: fg=1 shadow=3; gender uses DYNAMIC_COLOR_2/1 (pal 11 / 10). local HB_TEXT = { - fg = { 64 / 255, 64 / 255, 64 / 255, 1 }, - shadow = { 216 / 255, 208 / 255, 176 / 255, 1 }, + fg = { c5(8), c5(8), c5(8), 1 }, + shadow = { c5(27), c5(26), c5(22), 1 }, } local HB_MALE = { fg = { 65 / 255, 205 / 255, 255 / 255, 1 }, diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index e51a342a..16091099 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -380,12 +380,13 @@ end local function choice_hooks() return { - pushMsg = function(text) Ui.push(text) end, - askYesNo = function(cb) Ui.askYesNo(cb) end, + pushMsg = function(text, cb) Ui.push(text, cb) end, + askYesNo = function(a, b) Ui.askYesNo(a, b) end, askForget = function(labels, cb) Ui.askForget(labels, cb) end, headless = Battle._headless, } end +Battle._choiceHooksForTests = choice_hooks local function begin_evo_or_end() local st = Battle._st @@ -539,79 +540,58 @@ local function handle_enemy_faint(opts) local song = Audio.role(role) or ((st and st.wild) and 311 or 310) Audio.playSong(song) + local pname = st.playerName or "PLAYER" + local function push_defeated() + local trName = (st.trainerClassName and st.trainerClassName ~= "") + and (st.trainerClassName .. " " .. (st.trainerName or "")) + or (st.trainerName or "TRAINER") + -- pokefirered/data/battle_scripts_1.s:2912 + Ui.push(string.format("%s defeated\n%s!", pname, trName)) + end + local function push_lose_text_and_money() + local Trainers = require("src.core.game3.scripting.trainers") + local dialogs = Trainers.dialogs(st.trainerId) + local defeatSpeech = st.defeatText or (dialogs and dialogs.defeat) + -- pokefirered/data/battle_scripts_1.s:2915 + if defeatSpeech and defeatSpeech ~= "" then + Ui.push(defeatSpeech) + end + local Prize = require("src.core.game3.battle.prize") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + if session then + local info = Trainers.info(st.trainerId) + local lastLevel = info and tonumber(info.lastLevel) + if not lastLevel or lastLevel < 1 then + lastLevel = st.enemy and st.enemy.mon and tonumber(st.enemy.mon.level) or 1 + end + local gained = Prize.awardTrainerWin(session, st.trainerId, { + lastLevel = lastLevel, + double = st.double or false, + moneyMultiplier = st.moneyMultiplier or 1, + }) + if gained > 0 then + Ui.push(Prize.moneyMessage(session.name or pname, gained)) + end + end + end + if not st.wild and st.trainerId and not Battle._headless then + push_defeated() SwitchSeq.beginTrainerSlideIn(st, { headless = false, onDone = function() - local Trainers = require("src.core.game3.scripting.trainers") - local dialogs = Trainers.dialogs(st.trainerId) - local defeatSpeech = st.defeatText or (dialogs and dialogs.defeat) - if defeatSpeech and defeatSpeech ~= "" then - Ui.push(defeatSpeech) - end - local trName = (st.trainerClassName and st.trainerClassName ~= "") - and (st.trainerClassName .. " " .. (st.trainerName or "")) - or (st.trainerName or "TRAINER") - local pname = st.playerName or "PLAYER" - Ui.push(string.format("%s defeated\n%s!", pname, trName)) - - local Prize = require("src.core.game3.battle.prize") - local Runtime = package.loaded["src.core.game3.runtime"] - local session = Runtime and Runtime.getSession and Runtime.getSession() - if session then - local info = Trainers.info(st.trainerId) - local lastLevel = info and tonumber(info.lastLevel) - if not lastLevel or lastLevel < 1 then - lastLevel = st.enemy and st.enemy.mon and tonumber(st.enemy.mon.level) or 1 - end - local gained = Prize.awardTrainerWin(session, st.trainerId, { - lastLevel = lastLevel, - double = st.double or false, - moneyMultiplier = st.moneyMultiplier or 1, - }) - if gained > 0 then - Ui.push(Prize.moneyMessage(session.name or pname, gained)) - end - end + push_lose_text_and_money() begin_evo_or_end() end, }) Battle._phase = "switching" else if not st.wild and st.trainerId then - local Trainers = require("src.core.game3.scripting.trainers") - local dialogs = Trainers.dialogs(st.trainerId) - local defeatSpeech = st.defeatText or (dialogs and dialogs.defeat) - if defeatSpeech and defeatSpeech ~= "" then - Ui.push(defeatSpeech) - end - local trName = (st.trainerClassName and st.trainerClassName ~= "") - and (st.trainerClassName .. " " .. (st.trainerName or "")) - or (st.trainerName or "TRAINER") - local pname = st.playerName or "PLAYER" - Ui.push(string.format("%s defeated\n%s!", pname, trName)) - - local Prize = require("src.core.game3.battle.prize") - local Runtime = package.loaded["src.core.game3.runtime"] - local session = Runtime and Runtime.getSession and Runtime.getSession() - if session then - local info = Trainers.info(st.trainerId) - local lastLevel = info and tonumber(info.lastLevel) - if not lastLevel or lastLevel < 1 then - lastLevel = st.enemy and st.enemy.mon and tonumber(st.enemy.mon.level) or 1 - end - local gained = Prize.awardTrainerWin(session, st.trainerId, { - lastLevel = lastLevel, - double = st.double or false, - moneyMultiplier = st.moneyMultiplier or 1, - }) - if gained > 0 then - Ui.push(Prize.moneyMessage(session.name or pname, gained)) - end - end - else - Ui.push("You won the battle!") + push_defeated() + push_lose_text_and_money() end + -- pokefirered/src/battle_main.c:3764 begin_evo_or_end() end else diff --git a/src/core/game3/battle/learn_move.lua b/src/core/game3/battle/learn_move.lua index 1b6bbdc5..8d3dc128 100644 --- a/src/core/game3/battle/learn_move.lua +++ b/src/core/game3/battle/learn_move.lua @@ -237,14 +237,6 @@ function LearnMove.begin(opts) say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!") finish(ok) else - -- Two contexts call LearnMove.begin: - -- * Battle (Ui.push): pushMsg ignores the callback → pump() watches - -- Ui.dialogPending() and calls finish() once the message is dismissed. - -- * Party menu / TM use (PartyMenu.showMessage): pushMsg DOES call cb - -- when the player presses A → finish() fires inline via the callback. - -- Both are handled: set _pending="done" for the pump() path, and also - -- pass a callback to say() for the direct path. The guard prevents - -- double-finish if both somehow fire. LearnMove._pendingResult = ok LearnMove._pending = "done" say(LearnMove._name .. " learned\n" .. LearnMove._moveName .. "!", function() diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 50af25cb..b4d1de90 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -32,6 +32,7 @@ Ui._menuIndex = 1 Ui._moveIndex = 1 Ui._st = nil Ui._pendingCommand = nil +Ui._pendingYesNo = nil Ui._session = nil -- pret sBattlerCoords (singles) — CreateSprite CENTER before pic y_offset @@ -70,6 +71,7 @@ function Ui.reset(opts) Ui._moveIndex = 1 Ui._st = nil Ui._pendingCommand = nil + Ui._pendingYesNo = nil if not Ui._headless then pcall(BattleChrome.install, nil) end @@ -84,10 +86,13 @@ function Ui.bindSession(session) Ui._session = session end -function Ui.push(text) - if not text or text == "" then return end +function Ui.push(text, cb) + if not text or text == "" then + if cb then cb() end + return + end Ui._log[#Ui._log + 1] = text - Ui._queue[#Ui._queue + 1] = text + Ui._queue[#Ui._queue + 1] = cb and { text = text, cb = cb } or text end function Ui.busy() @@ -108,7 +113,9 @@ function Ui.dialogPending() end --- YES/NO during award/learn (Choice.yesNo). cb(true|false) -function Ui.askYesNo(cb) +function Ui.askYesNo(a, b) + local cb = (type(a) == "function") and a or b + local prompt = (type(a) == "string") and a or nil if Ui._headless then if cb then cb(false) end return @@ -117,9 +124,37 @@ function Ui.askYesNo(cb) if cb then cb(false) end return end + if prompt and prompt ~= "" and Message and Message.show then + Ui._log[#Ui._log + 1] = prompt + Message.show(prompt, { frame = "battle", battle = true, stay = true }) + -- pokefirered/data/battle_scripts_1.s:3127 + Ui._pendingYesNo = cb or false + return + end Choice.yesNo(cb, { left = 22, top = 8 }) end +local function open_pending_yesno() + if Ui._pendingYesNo == nil then return false end + if not (Message and Message.isOpen and Message.isOpen()) then + Ui._pendingYesNo = nil + return false + end + if not (Message.isWaiting and Message.isWaiting()) then return true end + local cb = Ui._pendingYesNo + Ui._pendingYesNo = nil + -- pokefirered/data/battle_scripts_1.s:3129 + Choice.yesNo(function(yes) + if Message.isOpen() and Message.close then Message.close() end + if cb then cb(yes) end + end, { left = 22, top = 8 }) + return true +end + +function Ui.yesNoPending() + return Ui._pendingYesNo ~= nil +end + --- Multi-choice forget list. cb(0-based index) or cb(-1)/cb(127) on cancel. function Ui.askForget(labels, cb) if Ui._headless then @@ -231,11 +266,20 @@ function Ui.takeCommand() return c end +local function pop_queue() + local item = table.remove(Ui._queue, 1) + if type(item) == "table" then return item.text, item.cb end + return item, nil +end + local function show_next() if Ui._showing then return end if #Ui._queue == 0 then return end - local text = table.remove(Ui._queue, 1) - if Ui._headless then return end + local text, cb = pop_queue() + if Ui._headless then + if cb then cb() end + return + end if Message and Message.show then Ui._showing = true Message.show(text, { @@ -243,17 +287,24 @@ local function show_next() battle = true, done = function() Ui._showing = false + if cb then cb() end end, }) + elseif cb then + cb() end end function Ui.pump() if Ui._headless then - while #Ui._queue > 0 do table.remove(Ui._queue, 1) end + while #Ui._queue > 0 do + local _, cb = pop_queue() + if cb then cb() end + end Ui._showing = false return true end + if open_pending_yesno() then return false end if Message and Message.isOpen and Message.isOpen() then return false end @@ -411,8 +462,8 @@ local function draw_mon_sprite(battler, base, back) love.graphics.setColor(shade, shade, shade, a) end local hFlip = (pres and pres.hFlip) and true or false - local sx = (hFlip and -1 or 1) * ((pres and pres.sx) or scale) - local sy = (pres and pres.sy) or scale + local sx = (hFlip and -1 or 1) * scale * ((pres and pres.sx) or 1) + local sy = scale * ((pres and pres.sy) or 1) love.graphics.draw(entry.image, cx, cy, 0, sx, sy, 32, 32) else -- Placeholder silhouette so lunge/shake is visible before full pic extract. @@ -489,7 +540,7 @@ local function draw_move_menu(st) end -local function draw_trainer_sprites(stage) +local function draw_enemy_trainer(stage) if not stage or not stage.trainer then return end local TrainerPic = require("src.core.game3.trainer_pic") local te = stage.trainer.enemy @@ -503,6 +554,11 @@ local function draw_trainer_sprites(stage) end end end +end + +local function draw_player_trainer(stage) + if not stage or not stage.trainer then return end + local TrainerPic = require("src.core.game3.trainer_pic") local tp = stage.trainer.player if tp and tp.visible then local entry = TrainerPic.back(tp.gender or 0) @@ -691,12 +747,14 @@ function Ui.draw(w, h) -- 3. In front of Enemy / Behind Player / Mid-field (Z: 101 .. 199) -- 4. Player Mon (Z: 200) -- 5. In front of Player & Global Foreground (Z: 201 .. 999) - draw_trainer_sprites(stage) + draw_enemy_trainer(stage) Anim.drawParticles(0, 99) if st then draw_mon_sprite(st.enemy, ENEMY_MON, false) end Anim.drawParticles(101, 199) + -- pokefirered/src/battle_anim_mons.c:1908 + draw_player_trainer(stage) if st then draw_mon_sprite(st.player, PLAYER_MON, true) end diff --git a/src/core/game3/display.lua b/src/core/game3/display.lua index 8d7cbf94..3aacf09b 100644 --- a/src/core/game3/display.lua +++ b/src/core/game3/display.lua @@ -30,7 +30,7 @@ function Display.ensureCanvas(which) return existing end end - local ok, canvas = pcall(love.graphics.newCanvas, Display.W, Display.H) + local ok, canvas = pcall(love.graphics.newCanvas, Display.W, Display.H, { dpiscale = 1 }) if not ok or not canvas then log("FAILED canvas create") return nil @@ -63,28 +63,42 @@ function Display.fit(winW, winH) end end + local dpiX, dpiY = 1, 1 + if gw > 0 and gh > 0 and love.graphics.getPixelDimensions then + local fw, fh = love.graphics.getPixelDimensions() + if fw and fw > 0 then dpiX = fw / gw end + if fh and fh > 0 then dpiY = fh / gh end + elseif love and love.graphics and love.graphics.getDPIScale then + local d = tonumber(love.graphics.getDPIScale()) + if d and d > 1e-6 then dpiX, dpiY = d, d end + end + local isPortrait = safeH > safeW - local scale, ox, oy, pw, ph + local k, ox, oy, pw, ph, scaleX, scaleY if isPortrait then -- On mobile portrait, scale to fit the available safe width cleanly. - scale = safeW / Display.W - pw = Display.W * scale - ph = Display.H * scale - ox = safeX + math.floor((safeW - pw) * 0.5) + k = math.max(1, math.floor(safeW * dpiX / Display.W + 1e-9)) + scaleX, scaleY = k / dpiX, k / dpiY + pw = Display.W * scaleX + ph = Display.H * scaleY + ox = safeX + (safeW - pw) * 0.5 -- In portrait, center the screen in the upper deck area (above the touch controls deck). local topDeckH = safeH * 0.48 - oy = safeY + math.max(4, math.floor((topDeckH - ph) * 0.5)) + oy = safeY + math.max(4, (topDeckH - ph) * 0.5) else -- Landscape / desktop: fit cleanly within safe area - scale = math.max(1, math.floor(math.min(safeW / Display.W, safeH / Display.H))) - pw = Display.W * scale - ph = Display.H * scale - ox = safeX + math.floor((safeW - pw) * 0.5) - oy = safeY + math.floor((safeH - ph) * 0.5) + k = math.max(1, math.floor(math.min(safeW * dpiX / Display.W, safeH * dpiY / Display.H) + 1e-9)) + scaleX, scaleY = k / dpiX, k / dpiY + pw = Display.W * scaleX + ph = Display.H * scaleY + ox = safeX + (safeW - pw) * 0.5 + oy = safeY + (safeH - ph) * 0.5 end + ox = math.floor(ox * dpiX + 1e-9) / dpiX + oy = math.floor(oy * dpiY + 1e-9) / dpiY - return scale, ox, oy, pw, ph + return scaleX, ox, oy, pw, ph, scaleY end local function beginOn(canvas) @@ -194,9 +208,9 @@ function Display.present(game, winW, winH) -- Void bars + blit our frame (game3 letterbox, not Gen2 Playfield). love.graphics.setColor(0.02, 0.04, 0.08, 1) love.graphics.rectangle("fill", 0, 0, winW, winH) - local scale, ox, oy = Display.fit(winW, winH) + local scale, ox, oy, _, _, scaleY = Display.fit(winW, winH) love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, ox, oy, 0, scale, scale) + love.graphics.draw(canvas, ox, oy, 0, scale, scaleY) return true end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 3ca7e9c5..6ab3349a 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -65,6 +65,9 @@ function Schema.newGame(opts) local Rng = require("src.core.game3.rng") session.trainerId = Rng.seedNewGame({ seed = opts.rngSeed }) Rng.captureToSession(session) + local Storage = require("src.core.game3.storage") + session.storage = Storage.new() + session.storage.items[1] = { id = 13, qty = 1 } -- pokefirered/src/player_pc.c:100 Options.ensure(session) -- Plan naming: text_speed / l_equals_a aliases mirror Options fields. session.options.text_speed = session.options.textSpeed @@ -104,6 +107,7 @@ function Schema.toSaveTable(session) playTime = session.playtime or session.playTime or { hours = 0, minutes = 0, seconds = 0 }, options = session.options, pc = session.pc, + storage = session.storage and require("src.core.game3.storage").serialize(session.storage) or nil, registeredItem = session.registeredItem, move_overlay = session.move_overlay or {}, trainerId = session.trainerId, @@ -144,6 +148,7 @@ function Schema.fromSaveTable(save) playtime = save.playTime or save.playtime or { hours = 0, minutes = 0, seconds = 0 }, options = save.options, pc = save.pc or { items = {} }, + storage = type(save.storage) == "table" and require("src.core.game3.storage").deserialize(save.storage) or nil, registeredItem = save.registeredItem, move_overlay = save.move_overlay or {}, trainerId = save.trainerId, diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index ddf3daf1..603b88dc 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -584,7 +584,7 @@ function Adapters.host(mod, game, world) end finish() end, - openPc = function(done) + openPc = function(done, pcOpts) local function finish() local okMsg, Message = pcall(require, "src.ui.game3.message") if okMsg and Message and Message.close then Message.close() end @@ -600,9 +600,12 @@ function Adapters.host(mod, game, world) if okHud and Hud and Hud.clearWaitButton then Hud.clearWaitButton() end a.log("[game3] openPc via game3 PcMenu") local PcMenu = require("src.ui.game3.pc_menu") + local bedroom = type(pcOpts) == "table" and pcOpts.bedroom == true PcMenu.show({ session = Runtime.getSession(), onClose = finish, + startMode = bedroom and "player_pc" or nil, + closeOnExit = bedroom, }) return end diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index 14423726..aaf64fa7 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -31,6 +31,13 @@ Natives.ALLOW = { if not (adapters and adapters.openPc) then return false end return yield_host(ctx, adapters, adapters.openPc) end, + -- pokefirered/src/player_pc.c:151 + ["special:" .. Std.SPECIAL.BedroomPC] = function(ctx, adapters) + if not (adapters and adapters.openPc) then return false end + return yield_host(ctx, adapters, function(done) + adapters.openPc(done, { bedroom = true }) + end) + end, ["special:" .. Std.SPECIAL.AnimatePcTurnOn] = function() return false end, ["special:" .. Std.SPECIAL.AnimatePcTurnOff] = function() return false end, ["special:" .. Std.SPECIAL.CreatePCMenu] = function(ctx, adapters) diff --git a/src/core/game3/scripting/stdscripts.lua b/src/core/game3/scripting/stdscripts.lua index 997940cf..2b54e698 100644 --- a/src/core/game3/scripting/stdscripts.lua +++ b/src/core/game3/scripting/stdscripts.lua @@ -22,6 +22,7 @@ Std.SPECIAL = { ShowRegionMap = 0xAF, AnimatePcTurnOn = 0xD6, AnimatePcTurnOff = 0xD7, + BedroomPC = 0xF9, -- pokefirered/data/specials.inc:260 PlayerPC = 0xFA, CreatePCMenu = 0x106, EnterHallOfFame = 0x110, -- 272 (special HallOfFame / GameClear) diff --git a/src/ui/game3/boot.lua b/src/ui/game3/boot.lua index 1b64a176..49cee69a 100644 --- a/src/ui/game3/boot.lua +++ b/src/ui/game3/boot.lua @@ -2,7 +2,7 @@ -- Oak onboarding lives in oak_speech.lua (pret oak_speech.c task chain). local Display = require("src.core.game3.display") -local FrlgFont = require("src.ui.game3.frlg_font") +local Window = require("src.ui.game3.window") local Audio = require("src.core.game3.audio") local OakSpeech = require("src.ui.game3.oak_speech") local IntroGuide = require("src.ui.game3.intro_guide") @@ -16,6 +16,7 @@ Boot.PHASE = { INTRO = "intro", COPYRIGHT = "copyright", TITLE = "title", + TITLE_CRY = "title_cry", MENU = "menu", CONTROLS = "controls", PIKACHU = "pikachu", @@ -137,11 +138,62 @@ function Boot.setHasContinue(state, yes) state.menuIndex = 1 end +function Boot.setContinueInfo(state, info) + state.continueInfo = info +end + +function Boot.continueInfoFromSave(save) + if type(save) ~= "table" then return nil end + local Flags = require("src.core.game3.scripting.flags") + local store = { flags = type(save.flags) == "table" and save.flags or {} } + local pt = type(save.playTime) == "table" and save.playTime + or type(save.playtime) == "table" and save.playtime or {} + local dex = type(save.dex) == "table" and save.dex or {} + local caught = dex.caught or dex.owned or {} + local counted, n = {}, 0 + for sp, on in pairs(caught) do + local id = tonumber(sp) + if id and on and on ~= 0 and not counted[id] and (dex.national or id <= 151) then + counted[id] = true + n = n + 1 + end + end + local name = tostring(save.name or save.playerName or "") + return { + name = name:sub(1, 7), + gender = tonumber(save.gender) or 0, + hours = tonumber(pt.hours) or 0, + minutes = tonumber(pt.minutes) or 0, + hasDex = Flags.getFlag(store, nil, Flags.IDS.SYS_POKEDEX_GET) == true, + dexCount = n, + badges = Flags.countBadges(store), + } +end + local function menuItems(state) if state.hasContinue then - return { "CONTINUE", "NEW GAME", "OPTION" } + return { "CONTINUE", "NEW GAME" } end - return { "NEW GAME", "OPTION" } + return { "NEW GAME" } +end + +Boot.menuItems = menuItems + +local function beginNewGame(state) + NamingChrome.install() + state.phase = Boot.PHASE.CONTROLS + state.guide = IntroGuide.beginControls(state.assets) + IntroGuide.start(state.guide) + state.timer = 0 + Audio.playSong(323) + return nil +end + +local function beginMenuFade(state, color, from, to, after) + state.fadeColor = color + state.fadeT = from + state.fadeTarget = to + state.fadeThen = after end local function enterTitle(state) @@ -199,13 +251,12 @@ function Boot.update(state, input, dt) if state.phase == Boot.PHASE.TITLE then TitleScreen.update(state, dt) if a() then - leaveTitle(state) - state.phase = Boot.PHASE.MENU - state.timer = 0 - Audio.playSe(5) - end - -- Idle timeout: cycle back to intro after 30s - if state.timer > 30.0 then + state.phase = Boot.PHASE.TITLE_CRY + state.cryFrames = 0 + state.white = 0 + state.whiteFading = false + Audio.playCry(6, 0) -- pokefirered/src/title_screen.c:715 + elseif state.timer >= 2700 / 60 then -- pokefirered/src/title_screen.c:435 leaveTitle(state) state.phase = Boot.PHASE.INTRO state.introMovie = IntroMovie.new(state.assets) @@ -214,31 +265,72 @@ function Boot.update(state, input, dt) return nil end - if state.phase == Boot.PHASE.MENU then - local items = menuItems(state) - if up() then - state.menuIndex = state.menuIndex - 1 - if state.menuIndex < 1 then state.menuIndex = #items end - Audio.playSe(5) - elseif down() then - state.menuIndex = state.menuIndex + 1 - if state.menuIndex > #items then state.menuIndex = 1 end - Audio.playSe(5) - elseif a() then - local choice = items[state.menuIndex] - Audio.playSe(5) - if choice == "CONTINUE" then - return { action = "continue" } - elseif choice == "NEW GAME" then - NamingChrome.install() - state.phase = Boot.PHASE.CONTROLS - state.guide = IntroGuide.beginControls(state.assets) - IntroGuide.start(state.guide) - state.timer = 0 - Audio.playSong(323) - elseif choice == "OPTION" then - print("[game3/boot] OPTION selected (stub)") + if state.phase == Boot.PHASE.TITLE_CRY then + TitleScreen.update(state, dt) + if not state.whiteFading then + if state.cryFrames < 90 then -- pokefirered/src/title_screen.c:722 + state.cryFrames = state.cryFrames + 1 + else + state.whiteFading = true + Audio.fadeOutBgm(4) -- pokefirered/src/title_screen.c:728 end + elseif state.white < 16 then + state.white = math.min(16, state.white + 2) -- pokefirered/src/palette.c:162 + else + leaveTitle(state) + state.whiteFading = false + state.timer = 0 + if not state.hasContinue then -- pokefirered/src/main_menu.c:317 + return beginNewGame(state) + end + state.phase = Boot.PHASE.MENU + state.menuIndex = 1 + beginMenuFade(state, "white", 16, 0, nil) -- pokefirered/src/main_menu.c:398 + end + return nil + end + + if state.phase == Boot.PHASE.MENU then + local t, target = state.fadeT or 0, state.fadeTarget or 0 + if t ~= target then + if t < target then + state.fadeT = math.min(target, t + 2) + else + state.fadeT = math.max(target, t - 2) + end + return nil + end + local pending = state.fadeThen + if pending then + state.fadeThen = nil + if pending == "continue" then + state.fadeT, state.fadeTarget = 0, 0 + return { action = "continue" } + elseif pending == "new_game" then + state.fadeT, state.fadeTarget = 0, 0 + return beginNewGame(state) + elseif pending == "title" then + state.fadeT, state.fadeTarget = 0, 0 + state.phase = Boot.PHASE.TITLE + state.timer = 0 + enterTitle(state) + Audio.playSong(278) -- pokefirered/src/title_screen.c:389 + end + return nil + end + local items = menuItems(state) + local pressed = function(k) return input and input.wasPressed and input:wasPressed(k) end + if pressed("a") then -- pokefirered/src/main_menu.c:570 + Audio.playSe(5) + local choice = items[state.menuIndex] + beginMenuFade(state, "black", 0, 16, choice == "CONTINUE" and "continue" or "new_game") + elseif pressed("b") then -- pokefirered/src/main_menu.c:577 + Audio.playSe(5) + beginMenuFade(state, "black", 0, 16, "title") + elseif up() and state.menuIndex > 1 then + state.menuIndex = state.menuIndex - 1 + elseif down() and state.menuIndex < #items then + state.menuIndex = state.menuIndex + 1 end return nil end @@ -271,15 +363,58 @@ function Boot.update(state, input, dt) return nil end -local function drawText(str, x, y, kind) - kind = kind or "NORMAL" - local colors = FrlgFont.COLOR and FrlgFont.COLOR[kind] or nil - if FrlgFont.draw then - FrlgFont.draw(str, x, y, { colors = colors, maxWidth = 220 }) - else - love.graphics.setColor(1, 1, 1, 1) - love.graphics.print(str, x, y) +local MENU_BG = { 139 / 255, 148 / 255, 255 / 255 } +local MENU_TEXT = { 98 / 255, 98 / 255, 98 / 255, 1 } +local MENU_SHADOW = { 213 / 255, 213 / 255, 205 / 255, 1 } +local MENU_FILL = { 1, 1, 1, 1 } +local ACCENT_MALE = { 4 / 31, 16 / 31, 31 / 31, 1 } +local ACCENT_FEMALE = { 31 / 31, 3 / 31, 21 / 31, 1 } +local WIN0V = { { 0x02, 0x5E }, { 0x62, 0x7E } } + +local function darkenOutside(W, H, x0, y0, x1, y1) + love.graphics.setColor(0, 0, 0, 7 / 16) -- pokefirered/src/main_menu.c:231 + love.graphics.rectangle("fill", 0, 0, W, y0) + love.graphics.rectangle("fill", 0, y1, W, H - y1) + love.graphics.rectangle("fill", 0, y0, x0, y1 - y0) + love.graphics.rectangle("fill", x1, y0, W - x1, y1 - y0) +end + +local function drawMainMenu(state, W, H) + love.graphics.clear(MENU_BG[1], MENU_BG[2], MENU_BG[3], 1) -- pokefirered/src/main_menu.c:199 + local info = state.continueInfo or {} + local head = { fg = MENU_TEXT, shadow = MENU_SHADOW, bg = MENU_FILL } + local stat = { + fg = (info.gender == 1) and ACCENT_FEMALE or ACCENT_MALE, -- pokefirered/src/main_menu.c:342 + shadow = MENU_SHADOW, + bg = MENU_FILL, + } + Window.stdFrame(Window.template(3, 1, 24, 10)) -- pokefirered/src/main_menu.c:84 + Window.stdFrame(Window.template(3, 13, 24, 2)) -- pokefirered/src/main_menu.c:93 + local x, y = 24, 8 + Window.printPx("CONTINUE", x + 2, y + 2, { colors = head }) + Window.printPx("PLAYER", x + 2, y + 18, { colors = stat }) -- pokefirered/src/main_menu.c:623 + Window.printPx(info.name or "", x + 62, y + 18, { colors = stat }) + Window.printPx("TIME", x + 2, y + 34, { colors = stat }) -- pokefirered/src/main_menu.c:636 + Window.printPx(string.format("%d:%02d", info.hours or 0, info.minutes or 0), x + 62, y + 34, { colors = stat }) + if info.hasDex then -- pokefirered/src/main_menu.c:648 + Window.printPx("POKéDEX", x + 2, y + 50, { colors = stat }) + Window.printPx(tostring(info.dexCount or 0), x + 62, y + 50, { colors = stat }) end + Window.printPx("BADGES", x + 2, y + 66, { colors = stat }) -- pokefirered/src/main_menu.c:672 + Window.printPx(tostring(info.badges or 0), x + 62, y + 66, { colors = stat }) + Window.printPx("NEW GAME", 24 + 2, 104 + 2, { colors = head }) + local rows = WIN0V[state.menuIndex] or WIN0V[1] -- pokefirered/src/main_menu.c:565 + darkenOutside(W, H, 18, rows[1], 222, rows[2]) + local t = state.fadeT or 0 + if t > 0 then + if state.fadeColor == "white" then + love.graphics.setColor(1, 1, 1, t / 16) + else + love.graphics.setColor(0, 0, 0, t / 16) + end + love.graphics.rectangle("fill", 0, 0, W, H) + end + love.graphics.setColor(1, 1, 1, 1) end function Boot.draw(state) @@ -296,39 +431,19 @@ function Boot.draw(state) return end + if state.phase == Boot.PHASE.TITLE_CRY then + TitleScreen.draw(state) + local white = state.white or 0 + if white > 0 then + love.graphics.setColor(1, 1, 1, white / 16) -- pokefirered/src/title_screen.c:726 + love.graphics.rectangle("fill", 0, 0, W, H) + love.graphics.setColor(1, 1, 1, 1) + end + return + end + if state.phase == Boot.PHASE.MENU then - -- Static title underlay (no particles) + dim - if state.titleBorder then - love.graphics.setColor(255 / 255, 255 / 255, 139 / 255, 1) - love.graphics.rectangle("fill", 0, 0, W, H) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(state.titleBorder, 0, 0) - if state.titleMon then love.graphics.draw(state.titleMon, 0, 0) end - if state.copyrightLayer then love.graphics.draw(state.copyrightLayer, 0, 0) end - if state.titleLogo then love.graphics.draw(state.titleLogo, 0, 0) end - elseif state.titleScreen then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(state.titleScreen, 0, 0) - else - love.graphics.setColor(0.1, 0.2, 0.4, 1) - love.graphics.rectangle("fill", 0, 0, W, H) - end - love.graphics.setColor(0, 0, 0, 0.55) - love.graphics.rectangle("fill", 0, 0, W, H) - local items = menuItems(state) - local boxX, boxY = 56, 48 - love.graphics.setColor(0.95, 0.95, 0.95, 1) - love.graphics.rectangle("fill", boxX, boxY, 128, 16 + #items * 18) - love.graphics.setColor(0.2, 0.35, 0.7, 1) - love.graphics.rectangle("line", boxX, boxY, 128, 16 + #items * 18) - for i, label in ipairs(items) do - local y = boxY + 8 + (i - 1) * 18 - love.graphics.setColor(0.1, 0.1, 0.15, 1) - if i == state.menuIndex then - drawText(">", boxX + 8, y) - end - drawText(label, boxX + 24, y) - end + drawMainMenu(state, W, H) return end diff --git a/src/ui/game3/mon_pic.lua b/src/ui/game3/mon_pic.lua index 7e478587..e87b6f1c 100644 --- a/src/ui/game3/mon_pic.lua +++ b/src/ui/game3/mon_pic.lua @@ -55,8 +55,8 @@ function MonPic.draw() if not MonPic.active then return end local tx = MonPic.left or 10 local ty = MonPic.top or 3 - -- pret CreateWindowFromRect(x, y, 8, 8) then sprite at 8*x+40, 8*y+40. - Window.stdFrame(Window.template(tx, ty, 8, 8)) + -- pokefirered/src/script_menu.c:1193 + Window.stdFrame(Window.template(tx + 1, ty + 1, 8, 8)) local cx = tx * Display.TILE + 40 local cy = ty * Display.TILE + 40 if MonPic._img then diff --git a/src/ui/game3/pc_menu.lua b/src/ui/game3/pc_menu.lua index 48de9bf0..2df91a11 100644 --- a/src/ui/game3/pc_menu.lua +++ b/src/ui/game3/pc_menu.lua @@ -49,9 +49,15 @@ function PcMenu.show(opts) PcMenu.open = true PcMenu._session = opts.session PcMenu._onClose = opts.onClose - PcMenu.mode = "root" + PcMenu._closeOnExit = opts.closeOnExit == true PcMenu.cursor = 1 - PcMenu._status = "Which PC would you like to access?" + if opts.startMode == "player_pc" then + PcMenu.mode = "player_pc" + PcMenu._status = "What would you like to do?" -- pokefirered/src/player_pc.c:160 + else + PcMenu.mode = "root" + PcMenu._status = "Which PC would you like to access?" + end Storage.ensure(PcMenu._session) se(2) -- SE_PC_ON / SE_PC_LOGIN Stack.push("pc_menu", PcMenu, { hideBelow = false }) @@ -261,10 +267,14 @@ function PcMenu.handleInput(input) elseif input:wasPressed("a") then local choice = playerOptions[PcMenu.cursor] if choice.id == "logoff" then - PcMenu.mode = "root" - PcMenu.cursor = 2 - PcMenu._status = "Which PC would you like to access?" - se(5) + if PcMenu._closeOnExit then + PcMenu.close() -- pokefirered/src/player_pc.c:257 + else + PcMenu.mode = "root" + PcMenu.cursor = 2 + PcMenu._status = "Which PC would you like to access?" + se(5) + end elseif choice.id == "withdraw" then local storage = Storage.ensure(PcMenu._session) if #storage.items < 1 then @@ -301,10 +311,14 @@ function PcMenu.handleInput(input) end end elseif input:wasPressed("b") then - PcMenu.mode = "root" - PcMenu.cursor = 2 - PcMenu._status = "Which PC would you like to access?" - se(5) + if PcMenu._closeOnExit then + PcMenu.close() + else + PcMenu.mode = "root" + PcMenu.cursor = 2 + PcMenu._status = "Which PC would you like to access?" + se(5) + end end return end diff --git a/tests/drivers/game3_u10_intro_layer.lua b/tests/drivers/game3_u10_intro_layer.lua new file mode 100644 index 00000000..8e4e9d63 --- /dev/null +++ b/tests/drivers/game3_u10_intro_layer.lua @@ -0,0 +1,86 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 4, 5) + local BattleBridge = require("src.core.game3.battle_bridge") + local species = tonumber(os.getenv("U10_SPECIES") or "") or 95 + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = species, level = 3 }, { fade = false }) + result(ok == true, "u10 wild battle started " .. tostring(err or "")) + if not ok then love.event.quit(1) return end + + local Anim = require("src.core.game3.battle.anim") + local TrainerPic = require("src.core.game3.trainer_pic") + local Pokemon = require("src.core.game3.pokemon") + + local function waitEnemyOx(target) + for _ = 1, 900 do + local p = Anim.present("enemy") + if p and p.visible ~= false and (p.ox or 0) >= target then return true end + U.wait(1) + end + return false + end + + local function recordOrder() + local monEntry = Pokemon.frontPic(species) + local backEntry = TrainerPic.back(Anim.stage().trainer.player.gender or 0) + local monImg = monEntry and monEntry.image + local backImg = backEntry and backEntry.image + local order = {} + local realDraw = love.graphics.draw + love.graphics.draw = function(img, ...) + if img == monImg then order[#order + 1] = "mon" end + if img == backImg then order[#order + 1] = "back" end + return realDraw(img, ...) + end + U.wait(2) + love.graphics.draw = realDraw + local iMon, iBack + for i, t in ipairs(order) do + if t == "mon" and not iMon then iMon = i end + if t == "back" and not iBack then iBack = i end + end + return iMon, iBack + end + + local reached = waitEnemyOx(-48) + result(reached, "u10 enemy mon reached crossing ox -48") + if reached then + local tp = Anim.stage().trainer.player + result(tp.visible == true, "u10 player trainer back pic visible at crossing") + local iMon, iBack = recordOrder() + result(iMon ~= nil and iBack ~= nil and iMon < iBack, "u10 enemy mon drawn under trainer back pic") + U.shot(game, DIR .. "/u10_01_cap_over_mon_crossing.png") + end + + reached = waitEnemyOx(-32) + if reached then + U.shot(game, DIR .. "/u10_02_cap_brim_over_mon_tail.png") + end + + reached = waitEnemyOx(0) + result(reached, "u10 intro slide finished") + if reached then + U.shot(game, DIR .. "/u10_03_slide_settled.png") + end + + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_u1_bedroom_pc.lua b/tests/drivers/game3_u1_bedroom_pc.lua new file mode 100644 index 00000000..2c00a90a --- /dev/null +++ b/tests/drivers/game3_u1_bedroom_pc.lua @@ -0,0 +1,106 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +local failures = 0 +local function result(ok, label) + if ok then + print("PASS " .. label) + else + failures = failures + 1 + print("FAIL " .. label) + end + return ok +end + +local function mods() + return package.loaded["src.ui.game3.pc_menu"], package.loaded["src.ui.game3.message"], + package.loaded["src.core.game3.scripting.space"] +end + +local function waitFor(pred, n) + for _ = 1, n do + if pred() then return true end + U.wait(1) + end + return pred() +end + +local function finish() + if failures == 0 then + print("PASS u1_bedroom_pc") + love.event.quit(0) + else + print("FAIL u1_bedroom_pc failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + U.wait(30) + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(120) + local Map = require("src.core.game3.map") + Map.load(nil, game, "FR_PLAYERS_HOUSE_2F", { x = 1, y = 2, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 1, 2, "up" + U.wait(90) + + local st = game.session.storage + result(st and st.items[1] and st.items[1].id == 13 and st.items[1].qty == 1, "new game PC holds POTION x1") + + U.tap(game, "a") + local booted = waitFor(function() + local _, Message = mods() + return Message and Message.isOpen and Message.isOpen() + end, 120) + U.wait(20) + if result(booted, "booted up the PC message") then + U.shot(game, DIR .. "/u1_01_booted_pc_msg.png") + end + + local PcMenu + local opened = false + for _ = 1, 6 do + U.tap(game, "a") + opened = waitFor(function() + PcMenu = mods() + return PcMenu and PcMenu.isOpen and PcMenu.isOpen() + end, 60) + if opened then break end + end + U.wait(10) + local Natives = package.loaded["src.core.game3.scripting.natives"] + result(not (Natives and Natives._logged and Natives._logged["special:249"]), "special 0xF9 not skipped") + if not result(opened and PcMenu.mode == "player_pc", "bedroom PC opens straight to player PC menu") then + return finish() + end + U.shot(game, DIR .. "/u1_02_player_pc_menu.png") + + U.tap(game, "a") + U.wait(15) + if result(PcMenu.mode == "withdraw_item", "WITHDRAW ITEM lists PC items") then + U.shot(game, DIR .. "/u1_03_withdraw_list_potion.png") + end + + U.tap(game, "a") + U.wait(15) + local Bag = require("src.core.game3.bag") + local got = Bag.has(game.session.bag, 13, 1) + if result(PcMenu.mode == "msg" and got, "POTION withdrawn into bag") then + U.shot(game, DIR .. "/u1_04_withdrew_potion.png") + end + + U.tap(game, "a") + U.wait(15) + U.tap(game, "b") + local closedOk = waitFor(function() + local _, Message, Space = mods() + local running = Space and Space.vm and Space.vm.isRunning and Space.vm:isRunning() + return not PcMenu.isOpen() and not running and not (Message and Message.isOpen and Message.isOpen()) + end, 120) + U.wait(20) + if result(closedOk, "B turns the PC off and returns to the field") then + U.shot(game, DIR .. "/u1_05_pc_off_field.png") + end + + finish() +end diff --git a/tests/drivers/game3_u2_monpic_centered.lua b/tests/drivers/game3_u2_monpic_centered.lua new file mode 100644 index 00000000..7822c0c1 --- /dev/null +++ b/tests/drivers/game3_u2_monpic_centered.lua @@ -0,0 +1,64 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +local failures = 0 +local function result(ok, label) + if ok then + print("PASS " .. label) + else + failures = failures + 1 + print("FAIL " .. label) + end + return ok +end + +local function finish() + if failures == 0 then + print("PASS u2_monpic_centered") + love.event.quit(0) + else + print("FAIL u2_monpic_centered failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + U.wait(30) + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(120) + local Map = require("src.core.game3.map") + Map.load(nil, game, "FR_OAKS_LAB", { x = 8, y = 5, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 8, 5, "up" + U.wait(90) + + local Chrome = require("src.ui.game3.chrome") + local MonPic = require("src.ui.game3.mon_pic") + local frame + local origStdFrame = Chrome.stdFrame + Chrome.stdFrame = function(tx, ty, tw, th) + if MonPic.active and tw == 8 and th == 8 then + frame = { tx, ty, tw, th } + end + return origStdFrame(tx, ty, tw, th) + end + + MonPic.show(1, 10, 3) + U.wait(20) + Chrome.stdFrame = origStdFrame + + result(MonPic.active and MonPic._img ~= nil, "showmonpic 10,3 opened with a front pic") + if not result(frame ~= nil, "pic window frame drawn") then + return finish() + end + result(frame[1] == 11 and frame[2] == 4, + ("window content at tiles (11,4), got (%s,%s)"):format(tostring(frame[1]), tostring(frame[2]))) + local picCx, picCy = MonPic.left * 8 + 40, MonPic.top * 8 + 40 + local frameCx, frameCy = (frame[1] + 4) * 8, (frame[2] + 4) * 8 + if result(picCx == frameCx and picCy == frameCy, + ("pic center (%d,%d) == window center (%d,%d)"):format(picCx, picCy, frameCx, frameCy)) then + U.shot(game, DIR .. "/u2_01_lab_starter_pic_centered.png") + end + + MonPic.hide() + finish() +end diff --git a/tests/drivers/game3_u3_healthbox_ghost.lua b/tests/drivers/game3_u3_healthbox_ghost.lua new file mode 100644 index 00000000..2bd93e8d --- /dev/null +++ b/tests/drivers/game3_u3_healthbox_ghost.lua @@ -0,0 +1,120 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +local function fail(label) + print("FAIL " .. label) + love.event.quit(1) + while true do coroutine.yield() end +end + +local function px8(img, x, y) + local r, g, b, a = img:getPixel(x, y) + return math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5), math.floor(a * 255 + 0.5) +end + +local function render(fn) + local canvas = love.graphics.newCanvas(240, 160) + canvas:setFilter("nearest", "nearest") + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.clear(0, 0, 0, 0) + love.graphics.setColor(1, 1, 1, 1) + fn() + love.graphics.pop() + return canvas:newImageData() +end + +local function key(r, g, b) return r .. "," .. g .. "," .. b end + +local function check_region(label, raw, full, tlX, tlY, x0, x1, y0, y1) + local counts = {} + for y = 0, 63 do + for x = 0, 127 do + local sx, sy = tlX + x, tlY + y + if sx >= 0 and sx < 240 and sy >= 0 and sy < 160 then + local r, g, b, a = px8(raw, sx, sy) + if a == 255 then + local k = key(r, g, b) + counts[k] = (counts[k] or 0) + 1 + end + end + end + end + local cream, best = nil, -1 + for k, n in pairs(counts) do + if n > best then cream, best = k, n end + end + print("U3 " .. label .. " baked cream", cream) + local erased, bad = 0, 0 + for y = y0, y1 do + for x = x0, x1 do + local rr, rg, rb = px8(raw, tlX + x, tlY + y) + if key(rr, rg, rb) ~= cream then + local fr, fg, fb = px8(full, tlX + x, tlY + y) + local k = key(fr, fg, fb) + if k == cream then + erased = erased + 1 + elseif k == key(rr, rg, rb) or k == "248,248,216" then + bad = bad + 1 + print("U3 " .. label .. " ghost px", x, y, k) + end + end + end + end + print("U3 " .. label .. " erased", erased, "ghost", bad) + return erased > 0 and bad == 0 +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local session = Runtime.getSession() + if not session then fail("u3 session") end + session.party = {} + Party.giveMon(session, 4, 5) + local BattleBridge = require("src.core.game3.battle_bridge") + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 3 }, { fade = false }) + if not ok then print("U3 start", err) fail("u3 battle start") end + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + for i = 1, 1500 do + if Ui._mode == "menu" then break end + if i % 20 == 0 then U.tap(game, "a") else U.wait(1) end + end + if Ui._mode ~= "menu" then fail("u3 reached action menu") end + U.wait(30) + local st = Battle._st + local Anim = require("src.core.game3.battle.anim") + local mon = st and st.player and st.player.mon + if not mon then fail("u3 player mon") end + mon.hp = 7 + Anim.syncDisplayFromState(st) + U.wait(20) + + local BattleChrome = require("src.ui.game3.battle_chrome") + local Healthbox = require("src.core.game3.battle.healthbox") + local ptlX, ptlY = Healthbox.PLAYER_CENTER.x - 32, Healthbox.PLAYER_CENTER.y - 16 + local etlX, etlY = Healthbox.ENEMY_CENTER.x - 32, Healthbox.ENEMY_CENTER.y - 16 + local rawP = render(function() BattleChrome.drawPlayerBox(ptlX, ptlY) end) + local fullP = render(function() Healthbox.draw("player", st.player) end) + local rawE = render(function() BattleChrome.drawEnemyBox(etlX, etlY) end) + local fullE = render(function() Healthbox.draw("enemy", st.enemy) end) + + local passP = check_region("player_slash", rawP, fullP, ptlX, ptlY, 64, 71, 24, 31) + local passE = check_region("enemy_lv", rawE, fullE, etlX, etlY, 56, 63, 10, 15) + + if not U.shot(game, DIR .. "/u3_01_hp_7_no_ghost_slash_or_lv.png") then fail("u3 shot") end + + local all = true + if passP then print("PASS u3 player healthbox no ghost slash at 1-digit hp") else print("FAIL u3 player healthbox no ghost slash at 1-digit hp") all = false end + if passE then print("PASS u3 enemy healthbox no ghost Lv") else print("FAIL u3 enemy healthbox no ghost Lv") all = false end + love.event.quit(all and 0 or 1) + while true do coroutine.yield() end +end diff --git a/tests/drivers/game3_u5_main_menu.lua b/tests/drivers/game3_u5_main_menu.lua new file mode 100644 index 00000000..2c28c631 --- /dev/null +++ b/tests/drivers/game3_u5_main_menu.lua @@ -0,0 +1,85 @@ +local U = require("tests.drivers.util") +local Boot = require("src.ui.game3.boot") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local ok = true + local function expect(cond, label) + if cond then + print("PASS " .. label) + else + print("FAIL " .. label) + ok = false + end + end + local function waitFor(pred, limit) + for _ = 1, limit do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function phase() return game.boot and game.boot.phase end + + local n = 0 + while phase() ~= "title" and n < 1200 do + U.tap(game, "start") + U.wait(30) + n = n + 30 + end + expect(phase() == "title", "reached title") + if phase() ~= "title" then love.event.quit(1) return end + + Boot.setHasContinue(game.boot, true) + if not game.boot.continueInfo then + Boot.setContinueInfo(game.boot, { + name = "RED", gender = 0, hours = 1, minutes = 23, hasDex = true, dexCount = 12, badges = 2, + }) + end + U.wait(60) + U.shot(game, DIR .. "/u5_01_title.png") + + U.tap(game, "start") + U.wait(2) + expect(phase() == "title_cry", "start on title plays cry, no menu") + U.shot(game, DIR .. "/u5_02_cry_title_still_up.png") + + waitFor(function() return (game.boot.white or 0) >= 8 end, 200) + expect(phase() == "title_cry" and (game.boot.white or 0) >= 8, "fade to white after cry wait") + U.shot(game, DIR .. "/u5_03_white_fade.png") + + waitFor(function() return phase() == "menu" end, 60) + expect(phase() == "menu", "separate main menu after white fade") + waitFor(function() return (game.boot.fadeT or 0) == 0 end, 30) + U.wait(2) + expect(not game.boot._titleActive, "title art torn down under menu") + U.shot(game, DIR .. "/u5_04_main_menu_continue.png") + + U.tap(game, "down") + U.wait(3) + expect(game.boot.menuIndex == 2, "down moves to NEW GAME") + U.tap(game, "down") + U.wait(3) + expect(game.boot.menuIndex == 2, "down clamps at NEW GAME") + U.shot(game, DIR .. "/u5_05_main_menu_newgame_row.png") + + U.tap(game, "b") + waitFor(function() return phase() == "title" end, 60) + expect(phase() == "title", "B returns to title") + U.wait(40) + U.shot(game, DIR .. "/u5_06_b_back_to_title.png") + + Boot.setHasContinue(game.boot, false) + U.tap(game, "start") + local sawMenu = false + for _ = 1, 200 do + if phase() == "menu" then sawMenu = true end + if phase() ~= "title_cry" and phase() ~= "title" then break end + U.wait(1) + end + expect(not sawMenu and phase() == "controls", "no save goes straight to new game") + U.wait(40) + U.shot(game, DIR .. "/u5_07_nosave_new_game.png") + + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/game3_u6_whole_pixel_fit.lua b/tests/drivers/game3_u6_whole_pixel_fit.lua new file mode 100644 index 00000000..92f105c7 --- /dev/null +++ b/tests/drivers/game3_u6_whole_pixel_fit.lua @@ -0,0 +1,83 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local ok = true + local function expect(cond, label) + if cond then + print("PASS " .. label) + else + print("FAIL " .. label) + ok = false + end + end + + local function loadShot(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local okData, fd = pcall(love.filesystem.newFileData, bytes, "shot.png") + if not okData then return nil end + local okImg, img = pcall(love.image.newImageData, fd) + if not okImg then return nil end + return img + end + + local function blockStats(img, ox, oy, k) + local iw, ih = img:getDimensions() + local bad, total = 0, 0 + for by = 0, 159 do + for bx = 0, 239 do + local x0, y0 = ox + bx * k, oy + by * k + if x0 + k <= iw and y0 + k <= ih then + total = total + 1 + local r0, g0, b0 = img:getPixel(x0, y0) + local uniform = true + for yy = y0, y0 + k - 1 do + for xx = x0, x0 + k - 1 do + local r, g, b = img:getPixel(xx, yy) + if r ~= r0 or g ~= g0 or b ~= b0 then uniform = false break end + end + if not uniform then break end + end + if not uniform then bad = bad + 1 end + end + end + end + return bad, total + end + + U.wait(30) + local start = { map = "FR_PALLET_TOWN", x = 8, y = 10, facing = "down" } + game:_handleBootAction({ action = "new_game", name = "RED", rivalName = "BLUE", gender = 0, start = start }) + U.wait(120) + + local Display = require("src.core.game3.display") + local sizes = { + { 1360, 860, 5, "u6_01_landscape_1360x860_5x" }, + { 1024, 768, 4, "u6_02_landscape_1024x768_4x" }, + { 900, 1100, 3, "u6_03_portrait_900x1100_3x" }, + { 700, 1000, 2, "u6_04_portrait_700x1000_2x" }, + } + for _, sz in ipairs(sizes) do + love.window.updateMode(sz[1], sz[2], { resizable = true }) + U.wait(20) + local w, h = love.graphics.getDimensions() + local scale, ox, oy, _, _, scaleY = Display.fit(w, h) + print("U6 size", w, h, "scale", scale, scaleY, "ox", ox, "oy", oy) + expect(scale == sz[3] and scaleY == sz[3], sz[4] .. " whole scale " .. sz[3]) + expect(ox == math.floor(ox) and oy == math.floor(oy), sz[4] .. " whole-pixel offsets") + local path = DIR .. "/" .. sz[4] .. ".png" + U.shot(game, path) + local img = loadShot(path) + expect(img ~= nil, sz[4] .. " shot readable") + if img then + local bad, total = blockStats(img, ox, oy, sz[3]) + print("U6 blocks", sz[4], "nonuniform", bad, "of", total) + expect(total == 240 * 160 and bad == 0, sz[4] .. " every texel is a uniform " .. sz[3] .. "x" .. sz[3] .. " block") + end + end + + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/game3_u7_win_text.lua b/tests/drivers/game3_u7_win_text.lua new file mode 100644 index 00000000..2aded2c3 --- /dev/null +++ b/tests/drivers/game3_u7_win_text.lua @@ -0,0 +1,104 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local fails = 0 + local function check(cond, label) + if cond then + print("PASS " .. label) + else + fails = fails + 1 + print("FAIL " .. label) + end + end + + U.wait(30) + game:_handleBootAction({ action = "new_game", name = "RED", rivalName = "BLUE", gender = 0 }) + U.wait(120) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + local Party = require("src.core.game3.party") + session.party = {} + Party.giveMon(session, 6, 60) + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Anim = require("src.core.game3.battle.anim") + + local function onScreen() + if not Ui._showing then return "" end + local log = Ui.log() or {} + return log[#log - #(Ui._queue or {})] or "" + end + local function anyLine(needle) + for _, t in ipairs(Ui.log() or {}) do + if t:find(needle, 1, true) then return true end + end + return false + end + local function advanceUntil(pred, limit) + for f = 1, limit do + U.wait(1) + if pred() then return true end + if f % 20 == 0 then U.tap(game, "a") end + end + return false + end + local function trainerSprite() + local ok, s = pcall(Anim.stage) + local t = ok and s and s.trainer and s.trainer.enemy + return t and t.visible, t and t.ox + end + + local ok = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 2 }, {}) + check(ok, "u7 wild battle started") + local sawExp = advanceUntil(function() return onScreen():find("EXP. Points", 1, true) ~= nil end, 3000) + check(sawExp, "u7 wild EXP line reached") + if sawExp then + check(#(Ui._queue or {}) == 0, "u7 wild EXP line is the last queued text") + U.wait(150) + U.shot(game, DIR .. "/u7_01_wild_exp_is_last_text.png") + end + local sawWon = false + local ended = advanceUntil(function() + if anyLine("won the battle") then sawWon = true end + return not Battle.isActive() + end, 3000) + check(ended, "u7 wild battle ended") + check(not sawWon, "u7 wild win shows no 'You won the battle!'") + U.wait(90) + U.shot(game, DIR .. "/u7_02_wild_back_on_overworld.png") + + U.wait(30) + ok = BattleBridge.start(Runtime._mod, game, { species = 7, level = 5, trainerId = 326 }, { trainerId = 326 }) + check(ok, "u7 trainer battle started") + local sawDefeated = advanceUntil(function() return onScreen():find("defeated\n", 1, true) ~= nil end, 4000) + check(sawDefeated, "u7 trainer defeated line reached") + local sawLose + if sawDefeated then + check(not anyLine("wrong"), "u7 defeated line printed before lose text") + local vis, ox = trainerSprite() + check(not (vis and ox == 0), "u7 trainer not slid in while defeated line shows") + U.wait(150) + U.shot(game, DIR .. "/u7_03_trainer_defeated_before_slide_in.png") + sawLose = advanceUntil(function() return onScreen():find("wrong", 1, true) ~= nil end, 2000) + check(sawLose, "u7 trainer lose text follows defeated line") + if sawLose then + U.wait(150) + local vis2, ox2 = trainerSprite() + check(vis2 and ox2 == 0, "u7 trainer slid in for lose text") + U.shot(game, DIR .. "/u7_04_trainer_lose_text_after_slide_in.png") + end + end + local tEnded = advanceUntil(function() return not Battle.isActive() end, 3000) + check(tEnded, "u7 trainer battle ended") + + for i, t in ipairs(Ui.log() or {}) do print("U7 LOG", i, (t:gsub("\n", " / "))) end + if fails == 0 then + print("PASS u7 win text") + love.event.quit(0) + else + print("FAIL u7 win text") + love.event.quit(1) + end +end diff --git a/tests/drivers/game3_u8_learn_move_full.lua b/tests/drivers/game3_u8_learn_move_full.lua new file mode 100644 index 00000000..98d8f91e --- /dev/null +++ b/tests/drivers/game3_u8_learn_move_full.lua @@ -0,0 +1,144 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local fails = 0 + local function check(cond, label) + if cond then + print("PASS " .. label) + else + fails = fails + 1 + print("FAIL " .. label) + end + return cond + end + local function finish() + if fails == 0 then + print("PASS u8 learn move four-move paths") + love.event.quit(0) + else + print("FAIL u8 learn move four-move paths") + love.event.quit(1) + end + end + + U.wait(30) + local Schema = require("src.core.game3.save_schema_firered") + local Party = require("src.core.game3.party") + local Experience = require("src.core.game3.battle.experience") + local SummaryData = require("src.core.game3.summary_data") + local Runtime = require("src.core.game3.runtime") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local LearnMove = require("src.core.game3.battle.learn_move") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + + local session = Schema.newGame({ name = "RED", rivalName = "BLUE", gender = 0 }) + + local function fresh_charmander() + session.party = {} + Party.giveMon(session, 4, 6) + local mon = session.party[1] + Experience.syncExpToLevel(mon) + mon.exp = SummaryData.expForLevel(Experience.growthRate(mon), 7) - 3 + mon.moves = { 10, 45, 33, 39 } + mon.pp = { 35, 40, 35, 30 } + mon.maxPp = { 35, 40, 35, 30 } + return mon + end + + local function page() + return (Message.isOpen() and Message.currentPage and Message.currentPage()) or "" + end + local function anyLine(needle) + for _, t in ipairs(Ui.log() or {}) do + if t:find(needle, 1, true) then return true end + end + return false + end + local function advanceUntil(pred, limit) + for f = 1, limit do + U.wait(1) + if pred() then return true end + if f % 6 == 0 and not Choice.active and not Message._stay then U.tap(game, "a") end + end + return false + end + local function promptUp(needle) + return function() + return Choice.active and Choice.kind == "yesno" and Message.isWaiting() + and page():find(needle, 1, true) ~= nil + end + end + local function start_wild() + return BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 3 }, { fade = false }) + end + + fresh_charmander() + session.map = "FR_ROUTE_1" + session.x = 10 + session.y = 20 + session.flags = session.flags or {} + game:_enterField(session, "new_game") + U.wait(90) + + check(start_wild(), "u8 stop-path battle started") + if not check(advanceUntil(promptUp("Should a move be deleted"), 6000), "u8 delete prompt shows with YES/NO") then + U.shot(game, DIR .. "/u8_98_softlock_before_delete_prompt.png") + return finish() + end + check(anyLine("wants to learn") and anyLine("four moves"), "u8 wants-to-learn and four-moves lines shown first") + U.wait(20) + U.shot(game, DIR .. "/u8_01_delete_prompt_yesno.png") + U.tap(game, "down") + U.wait(4) + U.tap(game, "a") + if not check(advanceUntil(promptUp("Stop trying"), 600), "u8 NO opens stop-trying prompt") then + return finish() + end + U.wait(20) + U.shot(game, DIR .. "/u8_02_stop_trying_prompt.png") + U.tap(game, "a") + local sawDidNot = advanceUntil(function() + return Message.isWaiting() and page():find("did not learn", 1, true) ~= nil + end, 600) + if check(sawDidNot, "u8 YES to stop prints did-not-learn") then + U.wait(10) + U.shot(game, DIR .. "/u8_03_did_not_learn.png") + end + check(advanceUntil(function() return not Battle.isActive() end, 3000), "u8 stop-path battle ended") + local m1 = session.party[1] + check(m1 and table.concat(m1.moves, ",") == "10,45,33,39" and m1.level == 7, "u8 stop path kept moves at LV. 7") + check(not LearnMove.busy(), "u8 LearnMove idle after stop path") + U.wait(60) + + fresh_charmander() + check(start_wild(), "u8 forget-path battle started") + if not check(advanceUntil(promptUp("Should a move be deleted"), 6000), "u8 forget path reaches delete prompt") then + return finish() + end + U.wait(10) + U.tap(game, "a") + if not check(advanceUntil(function() return Choice.active and Choice.kind == "multi" end, 600), "u8 YES opens forget list") then + return finish() + end + U.wait(20) + U.shot(game, DIR .. "/u8_04_forget_list.png") + U.tap(game, "a") + local sawLearned = advanceUntil(function() + return Message.isWaiting() and page():find("learned", 1, true) ~= nil and anyLine("Poof") + end, 900) + if check(sawLearned, "u8 Poof chain reaches learned EMBER") then + check(anyLine("forgot how to"), "u8 forgot-how-to line shown") + U.wait(10) + U.shot(game, DIR .. "/u8_05_learned_ember.png") + end + check(advanceUntil(function() return not Battle.isActive() end, 3000), "u8 forget-path battle ended") + local m2 = session.party[1] + check(m2 and m2.moves[1] == 52 and m2.level == 7, "u8 EMBER replaced SCRATCH at LV. 7") + U.wait(60) + U.shot(game, DIR .. "/u8_06_back_on_route1.png") + finish() +end diff --git a/tests/drivers/game3_u9_sendout_scale.lua b/tests/drivers/game3_u9_sendout_scale.lua new file mode 100644 index 00000000..9df4cebd --- /dev/null +++ b/tests/drivers/game3_u9_sendout_scale.lua @@ -0,0 +1,119 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Anim = require("src.core.game3.battle.anim") + local Ui = require("src.core.game3.battle.ui") + local Pokemon = require("src.core.game3.pokemon") + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 4, 5) + + local function waitEmerge(side, maxFrames) + for _ = 1, maxFrames do + local p = Anim.present(side) + local s = Anim.stage() + if p and p.visible ~= false and (p.scale or 1) < 0.35 and s.ball and s.ball.visible + and (s.ball.frame or 0) >= 1 then + return true + end + if Ui.dialogPending and Ui.dialogPending() then + U.tap(game, "a") + else + U.wait(1) + end + end + return false + end + + local function drawnScale(side) + local b = Ui._st and Ui._st[side] + local sp = b and (b.species or (b.mon and Pokemon.speciesOf(b.mon))) + local entry = side == "player" and Pokemon.backPic(sp) or Pokemon.frontPic(sp) + local img = entry and entry.image + local got + local realDraw = love.graphics.draw + love.graphics.draw = function(i, x, y, r, sx, sy, ...) + if i == img and not got then got = { sx = sx, sy = sy } end + return realDraw(i, x, y, r, sx, sy, ...) + end + U.wait(1) + love.graphics.draw = realDraw + return got + end + + local function waitScale(side, target, maxFrames) + for _ = 1, maxFrames do + local p = Anim.present(side) + if p and p.visible ~= false and (p.scale or 1) >= target + and (target < 1 or (p.oy or 0) == 0) then return true end + U.wait(1) + end + return false + end + + local function waitFull(side, maxFrames) + return waitScale(side, 1, maxFrames) + end + + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 3 }, { fade = false }) + result(ok == true, "u9 wild battle started " .. tostring(err or "")) + if not ok then love.event.quit(1) return end + + local reached = waitEmerge("player", 1500) + result(reached, "u9 player send-out emerge started") + if reached then + local want = Anim.present("player").scale + local got = drawnScale("player") + result(got ~= nil and math.abs(got.sy) < 0.5 and math.abs(got.sy) >= want - 1e-6, + string.format("u9 player pic drawn scaled on open tick sy=%s pres=%.2f", tostring(got and got.sy), want)) + if waitScale("player", 0.55, 30) then + U.shot(game, DIR .. "/u9_01_player_emerge_half_size.png") + end + result(waitFull("player", 60), "u9 player emerge grew to full size") + U.shot(game, DIR .. "/u9_02_player_emerge_full.png") + end + + Battle.abort("win") + U.wait(120) + for _ = 1, 30 do U.tap(game, "a") U.wait(4) end + + local ok2, err2 = BattleBridge.start(Runtime._mod, game, + { trainerId = 326, party = { { species = 7, level = 5 } } }, + { wild = false, trainerId = 326, fade = false }) + result(ok2 == true, "u9 trainer battle started " .. tostring(err2 or "")) + if ok2 then + reached = waitEmerge("enemy", 2000) + result(reached, "u9 enemy send-out emerge started") + if reached then + local want = Anim.present("enemy").scale + local got = drawnScale("enemy") + result(got ~= nil and math.abs(got.sy) < 0.5 and math.abs(got.sy) >= want - 1e-6, + string.format("u9 enemy pic drawn scaled on open tick sy=%s pres=%.2f", tostring(got and got.sy), want)) + if waitScale("enemy", 0.55, 30) then + U.shot(game, DIR .. "/u9_03_enemy_emerge_half_size.png") + end + result(waitFull("enemy", 60), "u9 enemy emerge grew to full size") + U.shot(game, DIR .. "/u9_04_enemy_emerge_full.png") + end + end + + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/game3_battle_sendout_scale_test.lua b/tests/game3_battle_sendout_scale_test.lua new file mode 100644 index 00000000..9fb6ff02 --- /dev/null +++ b/tests/game3_battle_sendout_scale_test.lua @@ -0,0 +1,92 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Ui = require("src.core.game3.battle.ui") +local Anim = require("src.core.game3.battle.anim") +local BattleBg = require("src.core.game3.battle.bg") +local Healthbox = require("src.core.game3.battle.healthbox") +local BattleChrome = require("src.ui.game3.battle_chrome") +local TrainerPic = require("src.core.game3.trainer_pic") +local Pokemon = require("src.core.game3.pokemon") + +local IMG = { enemyMon = { tag = "enemyMon" }, playerMon = { tag = "playerMon" } } + +local calls = {} +local gfx = setmetatable({}, { __index = function() return function() end end }) +gfx.draw = function(img, x, y, r, sx, sy) + if img and img.tag then calls[img.tag] = { sx = sx, sy = sy } end +end +gfx.newQuad = function() return {} end +_G.love = { graphics = gfx } + +BattleBg.draw = function() return true end +Healthbox.draw = function() end +BattleChrome.drawPanel = function() end +BattleChrome.drawPartyBar = function() end +BattleChrome.manifest = function() return {} end +Anim.drawParticles = function() end +TrainerPic.front = function() return nil end +TrainerPic.back = function() return nil end +Pokemon.frontPic = function() return { image = IMG.enemyMon } end +Pokemon.backPic = function() return { image = IMG.playerMon } end + +local function near(a, b) return a and math.abs(a - b) < 1e-6 end + +local function drawWith(setup) + Ui.reset({ headless = false }) + Anim.reset({ headless = false }) + Ui._st = { enemy = { species = 16 }, player = { species = 4 } } + Anim.present("player").visible = true + Anim.present("enemy").visible = true + setup(Anim.present("player"), Anim.present("enemy")) + calls = {} + Ui.draw(240, 160) + return calls.playerMon, calls.enemyMon +end + +print("[test] emerge scale reaches the drawn pic") +local p, e = drawWith(function(pp, pe) + pp.scale = 0.23 + pe.scale = 0.16 +end) +check(p and near(p.sx, 0.23) and near(p.sy, 0.23), "player pic drawn at pres.scale 0.23") +check(e and near(e.sx, 0.16) and near(e.sy, 0.16), "enemy pic drawn at pres.scale 0.16") + +print("[test] default present draws at 1.0") +p, e = drawWith(function() end) +check(p and near(p.sx, 1) and near(p.sy, 1), "player pic drawn at 1.0") +check(e and near(e.sx, 1) and near(e.sy, 1), "enemy pic drawn at 1.0") + +print("[test] deform sx/sy composes with scale") +p = drawWith(function(pp) + pp.scale = 0.5 + pp.sx = 0.8 + pp.sy = 1.2 +end) +check(p and near(p.sx, 0.4) and near(p.sy, 0.6), "sx/sy deform multiplies scale") + +p = drawWith(function(pp) + pp.sx = 0.8 + pp.sy = 1.2 +end) +check(p and near(p.sx, 0.8) and near(p.sy, 1.2), "sx/sy deform unchanged at scale 1") + +print("[test] hFlip keeps scale magnitude") +p = drawWith(function(pp) + pp.scale = 0.5 + pp.hFlip = true +end) +check(p and near(p.sx, -0.5) and near(p.sy, 0.5), "hFlip gives -scale on x") + +print(failed == 0 and "[ok] all" or ("[FAIL] " .. failed .. " failed")) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_battle_win_text_test.lua b/tests/game3_battle_win_text_test.lua new file mode 100644 index 00000000..7db073f5 --- /dev/null +++ b/tests/game3_battle_win_text_test.lua @@ -0,0 +1,58 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Battle = require("src.core.game3.battle") +local Ui = require("src.core.game3.battle.ui") + +local function party() + return { { species = 6, level = 60, hp = 200, maxHp = 200, moves = { 53 }, pp = { 15 }, maxPp = { 15 } } } +end + +local function run(opts) + if Battle.isActive() then Battle.abort("win") end + local ok = Battle.start(opts) + local res = Battle.runToEnd() + local log = {} + for i, t in ipairs(Ui.log() or {}) do log[i] = t end + return ok, res, log +end + +local function find(log, needle) + for i, t in ipairs(log) do + if t:find(needle, 1, true) then return i end + end + return nil +end + +print("[test] 1. wild win prints nothing after EXP") +local ok, res, log = run({ wild = true, headless = true, playerParty = party(), foe = { species = 16, level = 2 } }) +check(ok and res == "win", "wild battle won") +check(find(log, "won the battle") == nil, "no 'You won the battle!' line") +check(log[#log] ~= nil and log[#log]:find("EXP. Points", 1, true) ~= nil, "last wild line is the EXP line") + +print("[test] 2. trainer win: defeated before lose text") +ok, res, log = run({ wild = false, headless = true, trainerId = 326, playerParty = party(), foe = { species = 7, level = 5, trainerId = 326 } }) +check(ok and res == "win", "trainer battle won") +local iDef = find(log, "defeated\n") +local iLose = find(log, "I picked the wrong") +check(iDef ~= nil, "defeated line present") +check(iLose ~= nil, "lose text present") +check(iDef and iLose and iDef < iLose, "defeated line precedes lose text") +check(find(log, "won the battle") == nil, "no 'You won the battle!' in trainer log") + +if failed > 0 then + print(string.format("[FAIL] %d check(s) failed", failed)) + os.exit(1) +end +print("[PASS] game3 battle win text") diff --git a/tests/game3_bedroom_pc_test.lua b/tests/game3_bedroom_pc_test.lua new file mode 100644 index 00000000..f930caa7 --- /dev/null +++ b/tests/game3_bedroom_pc_test.lua @@ -0,0 +1,136 @@ +#!/usr/bin/env luajit +-- pokefirered/src/player_pc.c:151 BedroomPC, pokefirered/src/player_pc.c:100 gNewGamePCItems + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +package.loaded["src.ui.game3.stack"] = { + push = function() end, + pop = function() end, + busy = function() return false end, + drawOrder = function() return {} end, +} +package.loaded["src.core.game3.audio"] = { + playSe = function() end, + playCry = function() end, +} + +print("[test] 1. BedroomPC special id + handler") +local Std = require("src.core.game3.scripting.stdscripts") +check(Std.SPECIAL.BedroomPC == 249, "BedroomPC = 249 (got " .. tostring(Std.SPECIAL.BedroomPC) .. ")") +local Natives = require("src.core.game3.scripting.natives") +check(Natives.ALLOW["special:249"] ~= nil, "special:249 handler registered") + +print("[test] 2. special 249 yields into openPc with bedroom opts") +local gotOpts, gotDone +local ctx = { mode = "bytecode", status = "running" } +local adapters = { + openPc = function(done, opts) + gotDone = done + gotOpts = opts + end, + log = function() end, +} +local yielded = Natives.special(ctx, 249, adapters) +check(yielded == true, "special 249 yields") +check(type(gotOpts) == "table" and gotOpts.bedroom == true, "openPc got bedroom=true") +check(ctx.nativePoll and ctx.nativePoll() == false, "native wait pending while PC open") +if gotDone then gotDone() end +check(ctx.nativePoll and ctx.nativePoll() == true, "native wait finishes on close") + +print("[test] 3. host openPc routes bedroom into player_pc with closeOnExit") +do + local shown + local realPc = package.loaded["src.ui.game3.pc_menu"] + local realRt = package.loaded["src.core.game3.runtime"] + package.loaded["src.ui.game3.pc_menu"] = { show = function(o) shown = o end } + package.loaded["src.core.game3.runtime"] = { + isActive = function() return true end, + getSession = function() return {} end, + } + local okA, Adapters = pcall(require, "src.core.game3.scripting.adapters") + local okH, host = false, nil + if okA then + okH, host = pcall(Adapters.host, nil, {}, nil) + end + check(okH and host and host.openPc, "Adapters.host builds openPc") + if okH and host and host.openPc then + host.openPc(function() end, { bedroom = true }) + check(shown and shown.startMode == "player_pc" and shown.closeOnExit == true, + "bedroom -> startMode player_pc, closeOnExit") + shown = nil + host.openPc(function() end) + check(shown and shown.startMode == nil and not shown.closeOnExit, "no opts -> root hub") + end + package.loaded["src.ui.game3.pc_menu"] = realPc + package.loaded["src.core.game3.runtime"] = realRt +end + +print("[test] 4. PcMenu bedroom mode: starts in player_pc, B and LOG OFF close") +local PcMenu = require("src.ui.game3.pc_menu") +local function input(btn) + return { wasPressed = function(_, b) return b == btn end } +end + +local closed = 0 +PcMenu.show({ session = {}, onClose = function() closed = closed + 1 end, + startMode = "player_pc", closeOnExit = true }) +check(PcMenu.mode == "player_pc", "starts in player_pc (got " .. tostring(PcMenu.mode) .. ")") +check(PcMenu._status == "What would you like to do?", "status What would you like to do?") +PcMenu.handleInput(input("b")) +check(not PcMenu.isOpen() and closed == 1, "B closes bedroom PC and fires onClose") + +PcMenu.show({ session = {}, onClose = function() closed = closed + 1 end, + startMode = "player_pc", closeOnExit = true }) +PcMenu.handleInput(input("up")) +check(PcMenu.cursor == 4, "cursor on LOG OFF") +PcMenu.handleInput(input("a")) +check(not PcMenu.isOpen() and closed == 2, "LOG OFF closes bedroom PC") + +PcMenu.show({ session = {}, onClose = function() closed = closed + 1 end }) +check(PcMenu.mode == "root", "default show opens root hub") +PcMenu.cursor = 2 +PcMenu.handleInput(input("a")) +check(PcMenu.mode == "player_pc", "hub -> player_pc") +PcMenu.handleInput(input("b")) +check(PcMenu.isOpen() and PcMenu.mode == "root" and closed == 2, "hub player_pc B returns to root") +PcMenu.close() + +print("[test] 5. New game seeds PC POTION; withdraw moves it to bag; save round-trip") +local Schema = require("src.core.game3.save_schema_firered") +local Bag = require("src.core.game3.bag") +local Storage = require("src.core.game3.storage") +local session = Schema.newGame({ rngSeed = 1 }) +local it = session.storage and session.storage.items and session.storage.items[1] +check(it and it.id == 13 and it.qty == 1, "newGame storage.items[1] = POTION x1") +check(session.storage and #session.storage.items == 1, "exactly one PC item") + +local saved = Schema.toSaveTable(session) +check(type(saved.storage) == "table" and saved.storage.items[1] and saved.storage.items[1].id == 13, + "toSaveTable writes storage") +local restored = Schema.fromSaveTable(saved) +check(restored.storage and restored.storage.items[1] and restored.storage.items[1].id == 13, + "fromSaveTable restores storage") +local legacy = Schema.fromSaveTable({ map = "FR_PALLET_TOWN", x = 1, y = 1 }) +check(legacy.storage == nil, "legacy save without storage gets no seeded POTION") + +check(Storage.withdrawItem(session, 1, 1) == true, "withdrawItem POTION ok") +check(Bag.has(session.bag, 13, 1), "bag has POTION") +check(#session.storage.items == 0, "PC storage empty after withdraw") + +if failed == 0 then + print("\nAll game3 bedroom PC tests passed.") + os.exit(0) +else + print("\n" .. failed .. " bedroom PC test(s) failed.") + os.exit(1) +end diff --git a/tests/game3_display_fit_test.lua b/tests/game3_display_fit_test.lua new file mode 100644 index 00000000..5c56366d --- /dev/null +++ b/tests/game3_display_fit_test.lua @@ -0,0 +1,89 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local win = { w = 1024, h = 768, pw = 1024, ph = 768 } +local canvasCalls = {} + +local fakeCanvas = {} +fakeCanvas.__index = fakeCanvas +function fakeCanvas:getWidth() return self.w end +function fakeCanvas:getHeight() return self.h end +function fakeCanvas:setFilter() end + +_G.love = { + graphics = { + getDimensions = function() return win.w, win.h end, + getPixelDimensions = function() return win.pw, win.ph end, + getDPIScale = function() return win.ph / win.h end, + newCanvas = function(w, h, settings) + canvasCalls[#canvasCalls + 1] = { w = w, h = h, settings = settings } + return setmetatable({ w = w, h = h }, fakeCanvas) + end, + }, +} + +package.loaded["src.core.SafeArea"] = { + windowRect = function() return 0, 0, win.w, win.h end, +} + +local Display = require("src.core.game3.display") + +local function isWhole(v) + return math.abs(v - math.floor(v + 0.5)) < 1e-6 +end + +local function case(w, h, dpi, expectK) + win.w, win.h = w, h + win.pw, win.ph = math.floor(w * dpi + 0.5), math.floor(h * dpi + 0.5) + local scale, ox, oy, pw, ph, scaleY = Display.fit(w, h) + scaleY = scaleY or scale + local dx, dy = win.pw / w, win.ph / h + local label = ("%dx%d dpi %g"):format(w, h, dpi) + check(isWhole(scale * dx) and isWhole(scaleY * dy), + label .. " whole physical pixels per texel (scale " .. tostring(scale) .. ")") + check(math.abs(scale * dx - scaleY * dy) < 1e-6, label .. " square texels") + check(isWhole(ox * dx) and isWhole(oy * dy), + label .. " offsets on physical pixels (" .. tostring(ox) .. "," .. tostring(oy) .. ")") + check(ox >= 0 and ox + pw <= w + 1e-6 and oy >= 0 and oy + ph <= h + 1e-6, + label .. " frame inside window") + if expectK then + check(math.abs(scale * dx - expectK) < 1e-6, + label .. " scale is " .. expectK .. "x (got " .. tostring(scale * dx) .. ")") + end +end + +case(1024, 768, 1, 4) +case(1360, 860, 1, 5) +case(1100, 1000, 1, 4) +case(900, 1100, 1, 3) +case(700, 1000, 1, 2) +case(1080, 1920, 1, 4) +case(390, 844, 3, 4) +case(392, 870, 2.755, 4) + +win.w, win.h, win.pw, win.ph = 1360, 860, 1360, 860 +local s, ox, oy = Display.fit(1360, 860) +check(s == 5 and ox == 80 and oy == 30, "desktop 1360x860 unchanged (5x at 80,30)") +win.w, win.h, win.pw, win.ph = 1100, 1000, 1100, 1000 +s, ox, oy = Display.fit(1100, 1000) +check(s == 4 and ox == 70 and oy == 180, "desktop 1100x1000 unchanged (4x at 70,180)") + +Display._canvas = nil +Display.ensureCanvas("main") +local last = canvasCalls[#canvasCalls] +check(last and last.w == 240 and last.h == 160, "canvas is 240x160") +check(last and last.settings and last.settings.dpiscale == 1, "canvas created at dpiscale 1") + +print(("game3_display_fit_test: %s (%d failed)"):format(failed == 0 and "PASS" or "FAIL", failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_healthbox_erase_test.lua b/tests/game3_healthbox_erase_test.lua new file mode 100644 index 00000000..4e17483c --- /dev/null +++ b/tests/game3_healthbox_erase_test.lua @@ -0,0 +1,149 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function find_upvalue(fn, name, seen) + seen = seen or {} + if type(fn) ~= "function" or seen[fn] then return nil end + seen[fn] = true + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then break end + if n == name and type(v) == "function" then return v end + if type(v) == "function" then + local r = find_upvalue(v, name, seen) + if r then return r end + end + i = i + 1 + end + return nil +end + +local bake_rgb +do + local ok, Extract = pcall(require, "src.import.gba.battle_chrome_extract") + if ok and type(Extract) == "table" then + for _, v in pairs(Extract) do + bake_rgb = find_upvalue(v, "bgr555_to_rgb8") + if bake_rgb then break end + end + end +end +if not bake_rgb then + bake_rgb = function(c) + local r5, g5, b5 = c % 32, math.floor(c / 32) % 32, math.floor(c / 1024) % 32 + return math.floor(r5 * 255 / 31 + 0.5), math.floor(g5 * 255 / 31 + 0.5), math.floor(b5 * 255 / 31 + 0.5) + end +end +check(bake_rgb ~= nil, "bake conversion available") + +local function bgr(r, g, b) return r + g * 32 + b * 1024 end +local function rgb8(r5, g5, b5) + local r, g, b = bake_rgb(bgr(r5, g5, b5)) + return { r, g, b } +end + +local PAL2 = rgb8(31, 31, 27) +local PAL1 = rgb8(8, 8, 8) +local PAL3 = rgb8(27, 26, 22) +check(PAL2[1] == 255 and PAL2[2] == 255 and PAL2[3] == 222, "bake cream is 255,255,222") +check(PAL3[1] == 222 and PAL3[2] == 214 and PAL3[3] == 181, "bake shadow is 222,214,181") + +local function to8(c) + return { math.floor(c[1] * 255 + 0.5), math.floor(c[2] * 255 + 0.5), math.floor(c[3] * 255 + 0.5) } +end +local function same(a, b) + return a[1] == b[1] and a[2] == b[2] and a[3] == b[3] +end +local function fmt(c) return string.format("%d,%d,%d", c[1], c[2], c[3]) end + +local log = {} +local phase = "pre" +local current = { 1, 1, 1, 1 } +_G.love = { + graphics = { + setColor = function(r, g, b, a) + if type(r) == "table" then current = { r[1], r[2], r[3], r[4] or 1 } + else current = { r, g, b, a or 1 } end + end, + rectangle = function(mode, x, y, w, h) + log[#log + 1] = { phase = phase, color = current, x = x, y = y, w = w, h = h } + end, + draw = function() end, + }, +} + +local textColors = {} +package.loaded["src.ui.game3.battle_chrome"] = { + drawPlayerBox = function() phase = "erase" end, + drawEnemyBox = function() phase = "erase" end, + drawHpBar = function() phase = "post" end, + drawExpBar = function() end, +} +package.loaded["src.ui.game3.frlg_font"] = { + CHAR_LV_2 = 1, CHAR_MALE = 2, CHAR_FEMALE = 3, + draw = function(_, _, _, opts) textColors[#textColors + 1] = opts and opts.colors end, + drawGlyph = function(_, _, _, opts) textColors[#textColors + 1] = opts and opts.colors end, + measure = function(s) return #tostring(s) * 5 end, + advance = function() return 6 end, +} +package.loaded["src.core.game3.battle.state"] = { + displayName = function(b) return b.name end, +} +package.loaded["src.core.game3.battle.anim"] = { + stage = function() return { healthbox = { player = { visible = true, ox = 0 }, enemy = { visible = true, ox = 0 } } } end, + displayHpRatio = function(_, b) return b.mon.hp / b.mon.maxHp, b.mon.hp, b.mon.maxHp end, + displayExpRatio = function() return 0 end, + present = function() return {} end, +} +package.loaded["src.ui.game3.summary_chrome"] = { drawStatusIcon = function() end } +package.loaded["src.core.game3.summary_data"] = { statusAilment = function() return 0 end } +package.loaded["src.core.game3.pokemon"] = { gender = function() return nil end } + +local Healthbox = require("src.core.game3.battle.healthbox") + +local function run(side, name) + log, textColors, phase = {}, {}, "pre" + Healthbox.draw(side, { + name = name, + mon = { species = 4, level = 5, hp = 7, maxHp = 19, gender = "X" }, + }) + local erased = {} + for _, e in ipairs(log) do + if e.phase == "erase" then erased[#erased + 1] = e end + end + return erased +end + +for _, side in ipairs({ "player", "enemy" }) do + print("[test] " .. side .. " placeholder erase uses bake cream") + local erased = run(side, side == "player" and "ABC" or "PIDGEY") + check(#erased > 0, side .. " erase painted pixels (" .. #erased .. ")") + local bad + for _, e in ipairs(erased) do + if not same(to8(e.color), PAL2) then bad = e break end + end + check(bad == nil, side .. " erase color == " .. fmt(PAL2) .. (bad and (" got " .. fmt(to8(bad.color))) or "")) + + print("[test] " .. side .. " text colors use bake ink/shadow") + local c = textColors[1] + check(c and same(to8(c.fg), PAL1), side .. " text fg == " .. fmt(PAL1) .. (c and (" got " .. fmt(to8(c.fg))) or "")) + check(c and same(to8(c.shadow), PAL3), side .. " text shadow == " .. fmt(PAL3) .. (c and (" got " .. fmt(to8(c.shadow))) or "")) +end + +if failed > 0 then + print("[test] FAILED " .. failed) + os.exit(1) +end +print("[test] all passed") diff --git a/tests/game3_learn_move_test.lua b/tests/game3_learn_move_test.lua new file mode 100644 index 00000000..e882d664 --- /dev/null +++ b/tests/game3_learn_move_test.lua @@ -0,0 +1,212 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Message = { open = false, _stay = false, _done = nil, _waiting = false, shown = {} } +function Message.show(text, opts) + opts = type(opts) == "table" and opts or {} + Message.open = true + Message._stay = opts.stay and true or false + Message._done = opts.done + Message._waiting = false + Message.shown[#Message.shown + 1] = tostring(text) + return Message +end +function Message.showStay(text, opts) + opts = opts or {} + opts.stay = true + return Message.show(text, opts) +end +function Message.isOpen() return Message.open end +function Message.isWaiting() return Message.open and Message._waiting end +function Message.isTyping() return Message.open and not Message._waiting end +function Message.tick() + if Message.open then Message._waiting = true end +end +function Message.skipReveal() + if Message.open then Message._waiting = true end +end +function Message.close() + local done = Message._done + Message.open = false + Message._stay = false + Message._waiting = false + Message._done = nil + if done then done() end +end +package.loaded["src.ui.game3.message"] = Message + +local Battle = require("src.core.game3.battle") +local Ui = require("src.core.game3.battle.ui") +local LearnMove = require("src.core.game3.battle.learn_move") +local Choice = require("src.ui.game3.choice") + +local function idx_of(shown, needle, from) + for i = from or 1, #shown do + if shown[i]:find(needle, 1, true) then return i end + end + return nil +end + +local function in_order(shown, needles) + local at = 0 + for _, n in ipairs(needles) do + local i = idx_of(shown, n, at + 1) + if not i then return false, n end + at = i + end + return true +end + +local function run(moves, answers) + Ui.reset({ headless = false }) + LearnMove.reset() + Choice.active = false + Message.open = false + Message._done = nil + Message.shown = {} + Battle._headless = false + local hooks = Battle._choiceHooksForTests and Battle._choiceHooksForTests() or { + pushMsg = function(text, cb) Ui.push(text, cb) end, + askYesNo = function(a, b) Ui.askYesNo(a, b) end, + askForget = function(labels, cb) Ui.askForget(labels, cb) end, + headless = false, + } + local mon = { species = 4, level = 19, moves = moves, pp = { 35, 40, 35, 30 }, maxPp = { 35, 40, 35, 30 } } + local result + local prompts = {} + local openedWhileTyping = false + local ok, err = pcall(function() + LearnMove.begin({ + mon = mon, + moveId = 52, + displayName = "CHARMANDER", + pushMsg = hooks.pushMsg, + askYesNo = hooks.askYesNo, + askForget = hooks.askForget, + headless = hooks.headless, + onDone = function(learned) result = learned end, + }) + for _ = 1, 200 do + if result ~= nil then break end + if Choice.active then + if Message.open and not Message._waiting then openedWhileTyping = true end + prompts[#prompts + 1] = Message.open and Message.shown[#Message.shown] or "" + local ans = table.remove(answers, 1) + if ans == "cancel" then Choice.cancel() else Choice.autoPick(ans) end + elseif Message.open and Message._waiting and not Message._stay then + Message.close() + else + Ui.pump() + LearnMove.pump() + Message.tick() + end + end + end) + return { + ok = ok, err = err, result = result, mon = mon, shown = Message.shown, + prompts = prompts, active = LearnMove.busy(), open = Message.open, + openedWhileTyping = openedWhileTyping, + } +end + +print("[test] 1. free slot: learned message dismissed finishes") +local r = run({ 10, 45 }, {}) +check(r.ok, "no error " .. tostring(r.err)) +check(r.result == true, "onDone(true)") +check(r.mon.moves[3] == 52, "EMBER in slot 3") +check(not r.active, "LearnMove idle") + +print("[test] 2. four moves: NO delete, YES stop -> did not learn") +r = run({ 10, 45, 33, 39 }, { false, true }) +check(r.ok, "no error " .. tostring(r.err)) +local ord, miss = in_order(r.shown, { "wants to learn", "already", "Should a move be deleted", "Stop trying", "did not learn" }) +check(ord, "message order (missing " .. tostring(miss) .. ")") +check(r.result == false, "onDone(false)") +check(not r.active, "LearnMove idle") +check(r.prompts[1] and r.prompts[1]:find("Should a move be deleted", 1, true) ~= nil, "delete prompt text stays up under YES/NO") +check(r.prompts[2] and r.prompts[2]:find("Stop trying", 1, true) ~= nil, "stop prompt text stays up under YES/NO") +check(not r.openedWhileTyping, "YES/NO never opens while the prompt is still typing") +check(not r.open, "no message left open") +check(r.mon.moves[1] == 10 and r.mon.moves[4] == 39, "moves unchanged") + +print("[test] 3. four moves: YES delete, forget slot 1 -> Poof chain") +r = run({ 10, 45, 33, 39 }, { true, 0 }) +check(r.ok, "no error " .. tostring(r.err)) +ord, miss = in_order(r.shown, { "wants to learn", "Should a move be deleted", "Poof", "forgot how to", "And...", "learned" }) +check(ord, "message order (missing " .. tostring(miss) .. ")") +check(r.result == true, "onDone(true)") +check(r.mon.moves[1] == 52, "EMBER replaced slot 1") +check(not r.active, "LearnMove idle") + +print("[test] 4. HM refusal then stop") +r = run({ 15, 45, 33, 39 }, { true, 0, false, true }) +check(r.ok, "no error " .. tostring(r.err)) +ord, miss = in_order(r.shown, { "Should a move be deleted", "HM moves can't", "Should a move be deleted", "Stop trying", "did not learn" }) +check(ord, "message order (missing " .. tostring(miss) .. ")") +check(r.result == false, "onDone(false)") +check(r.mon.moves[1] == 15, "CUT kept") + +print("[test] 5. forget list B-cancel returns to stop prompt") +r = run({ 10, 45, 33, 39 }, { true, "cancel", true }) +check(r.ok, "no error " .. tostring(r.err)) +check(r.result == false, "onDone(false)") +check(idx_of(r.shown, "did not learn") ~= nil, "did not learn shown") + +print("[test] 6. Ui.askYesNo(cb) single-arg form still works") +Ui.reset({ headless = false }) +Message.open = false +Message.shown = {} +local got +Ui.askYesNo(function(yes) got = yes end) +check(Choice.active and not Message.open, "choice open without a prompt message") +Choice.autoPick(true) +check(got == true, "cb(true)") + +print("[test] 6b. Ui.askYesNo(prompt, cb) waits for the prompt to finish printing") +Ui.reset({ headless = false }) +Message.open = false +Message.shown = {} +Choice.active = false +got = nil +Ui.askYesNo("Should a move be deleted?", function(yes) got = yes end) +check(Message.open and Message._stay and not Message._waiting, "prompt shown as a typing stay message") +check(not Choice.active, "YES/NO not open while typing") +check(Ui.pump() == false and not Choice.active, "pump while typing keeps YES/NO closed") +Message.tick() +check(Ui.pump() == false and Choice.active and Choice.kind == "yesno", "YES/NO opens once the text is waiting") +Choice.autoPick(false) +check(got == false and not Message.open, "cb(false) and prompt closed") + +print("[test] 7. Ui.push cb fires on dismiss and in headless pump") +Ui.reset({ headless = false }) +local fired = 0 +Ui.push("hello", function() fired = fired + 1 end) +Ui.push("plain") +check(Ui.log()[1] == "hello" and Ui.log()[2] == "plain", "log stays strings") +Ui.pump() +check(fired == 0 and Message.open, "cb waits for dismiss") +Message.close() +check(fired == 1, "cb fired on dismiss") +Ui.reset({ headless = true }) +Ui.push("x", function() fired = fired + 1 end) +Ui.pump() +check(fired == 2, "headless pump fires cb") +Ui.push("", function() fired = fired + 1 end) +check(fired == 3, "empty text fires cb immediately") + +if failed > 0 then + print(string.format("[FAIL] %d check(s) failed", failed)) + os.exit(1) +end +print("[PASS] game3 learn move battle contract") diff --git a/tests/game3_main_menu_test.lua b/tests/game3_main_menu_test.lua new file mode 100644 index 00000000..3b348bc9 --- /dev/null +++ b/tests/game3_main_menu_test.lua @@ -0,0 +1,188 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +package.loaded["src.ui.game3.intro_guide"] = { + beginControls = function() return { kind = "controls" } end, + beginPikachu = function() return { kind = "pikachu" } end, + start = function() end, + update = function() return false end, + draw = function() end, +} +package.loaded["src.ui.game3.naming_chrome"] = { + install = function() return true end, + ready = function() return true end, + get = function() return nil end, +} + +local Audio = require("src.core.game3.audio") +local calls = {} +Audio.playCry = function(sp) calls[#calls + 1] = "cry:" .. tostring(sp) return true end +Audio.fadeOutBgm = function(s) calls[#calls + 1] = "fadeout:" .. tostring(s) return true end +Audio.playSong = function(id) calls[#calls + 1] = "song:" .. tostring(id) return true end +Audio.playSe = function(id) calls[#calls + 1] = "se:" .. tostring(id) return true end + +local Boot = require("src.ui.game3.boot") +local FrlgFont = require("src.ui.game3.frlg_font") +local Chrome = require("src.ui.game3.chrome") + +local function keys(...) + local set = {} + for _, k in ipairs({ ... }) do set[k] = true end + return { wasPressed = function(_, k) return set[k] == true end } +end + +local function step(state, input, n) + local action + for _ = 1, (n or 1) do + action = Boot.update(state, input, 1 / 60) or action + end + return action +end + +local function atTitle(hasContinue) + local boot = Boot.new() + Boot.setHasContinue(boot, hasContinue) + step(boot, keys("a")) + step(boot, nil) + for _ = 1, 120 do + if boot.phase == Boot.PHASE.TITLE then break end + step(boot, nil) + end + return boot +end + +local function throughCry(boot) + for _ = 1, 300 do + if boot.phase ~= Boot.PHASE.TITLE_CRY then break end + step(boot, nil) + end +end + +print("[test] 1. START on the title plays the cry, not the menu") +local boot = atTitle(true) +check(boot.phase == Boot.PHASE.TITLE, "reached TITLE") +calls = {} +step(boot, keys("start")) +check(boot.phase == "title_cry", "START -> title_cry (got " .. tostring(boot.phase) .. ")") +check(calls[1] == "cry:6", "Charizard cry played") +step(boot, nil, 89) +check(boot.phase == "title_cry", "still title_cry after 89 frames") +check((boot.white or 0) == 0, "no white yet during the 90-frame wait") +step(boot, nil, 2) +local sawFade = false +for _, c in ipairs(calls) do if c == "fadeout:4" then sawFade = true end end +check(sawFade, "FadeOutBGM(4) after the wait") +throughCry(boot) +check(boot.phase == Boot.PHASE.MENU, "with a save -> MENU after the white fade") +check(boot._titleActive == false, "title torn down") +check(boot.fadeT == 16 and boot.fadeColor == "white", "menu starts fading in from white") + +print("[test] 2. Menu rows and cursor clamp") +local items = Boot.menuItems(boot) +check(#items == 2 and items[1] == "CONTINUE" and items[2] == "NEW GAME", "CONTINUE / NEW GAME only") +local hasOption = false +for _, it in ipairs(items) do if it == "OPTION" then hasOption = true end end +check(not hasOption, "no OPTION row") +step(boot, nil, 10) +check(boot.fadeT == 0, "fade-in finished") +step(boot, keys("up")) +check(boot.menuIndex == 1, "up at top stays at 1") +step(boot, keys("down")) +step(boot, nil) +step(boot, keys("down")) +check(boot.menuIndex == 2, "down at bottom stays at 2") + +print("[test] 3. Menu draw never paints the title art") +local sentinels = { titleBorder = {}, titleMon = {}, titleLogo = {}, copyrightLayer = {}, titleScreen = {} } +for k, v in pairs(sentinels) do boot[k] = v end +local drewTitle, clearColor = false, nil +local origFont, origFrame = FrlgFont.draw, Chrome.stdFrame +FrlgFont.draw = function() return 0 end +Chrome.stdFrame = function() end +_G.love = { + graphics = { + clear = function(r, g, b) clearColor = { r, g, b } end, + setColor = function() end, + rectangle = function() end, + draw = function(img) + for _, v in pairs(sentinels) do if img == v then drewTitle = true end end + end, + }, +} +Boot.draw(boot) +_G.love = nil +FrlgFont.draw, Chrome.stdFrame = origFont, origFrame +check(not drewTitle, "no title BG image drawn under the menu") +check(clearColor and math.abs(clearColor[1] * 255 - 139) < 1 and math.abs(clearColor[3] * 255 - 255) < 1, + "backdrop is main_menu bg.pal[0]") + +print("[test] 4. B returns to the title after a black fade") +calls = {} +step(boot, keys("b")) +check(calls[1] == "se:5", "SE_SELECT on B") +check(boot.phase == Boot.PHASE.MENU and boot.fadeColor == "black", "fading to black") +step(boot, nil, 12) +check(boot.phase == Boot.PHASE.TITLE, "back on TITLE (got " .. tostring(boot.phase) .. ")") +check(boot._titleActive == true, "title re-entered") + +print("[test] 5. No save goes straight to the new game") +local fresh = atTitle(false) +step(fresh, keys("start")) +local sawMenu = false +for _ = 1, 300 do + if fresh.phase == Boot.PHASE.MENU then sawMenu = true end + if fresh.phase ~= Boot.PHASE.TITLE_CRY then break end + step(fresh, nil) +end +check(not sawMenu, "no menu frame without a save") +check(fresh.phase == Boot.PHASE.CONTROLS, "CONTROLS after the white fade (got " .. tostring(fresh.phase) .. ")") + +print("[test] 6. A on CONTINUE fades to black, then continues") +local cont = atTitle(true) +step(cont, keys("start")) +throughCry(cont) +step(cont, nil, 10) +local action = step(cont, keys("a")) +check(action == nil and cont.fadeColor == "black", "A starts a black fade first") +action = step(cont, nil, 12) +check(action and action.action == "continue", "continue action after the fade") + +print("[test] 7. Continue stats from a save") +local info = Boot.continueInfoFromSave({ + name = "BRYANTHABOI", + gender = 1, + playTime = { hours = 12, minutes = 5 }, + flags = { ["2089"] = true, ["2080"] = true, ["2081"] = true }, + dex = { caught = { [1] = true, [4] = true, [200] = true }, national = false }, +}) +check(info.name == "BRYANTH", "name clipped to 7") +check(info.hours == 12 and info.minutes == 5, "play time") +check(info.hasDex == true, "FLAG_SYS_POKEDEX_GET read") +check(info.badges == 2, "badge count 2 (got " .. tostring(info.badges) .. ")") +check(info.dexCount == 2, "kanto caught count 2 (got " .. tostring(info.dexCount) .. ")") + +print("[test] 8. Title idle restart at 2700 frames") +local idle = atTitle(false) +step(idle, nil, 1790) +check(idle.phase == Boot.PHASE.TITLE, "still TITLE at 30s") +step(idle, nil, 920) +check(idle.phase == Boot.PHASE.INTRO, "restarts intro by 45s") + +if failed == 0 then + print("\nAll game3 main menu tests passed.") + os.exit(0) +else + print("\n" .. failed .. " main menu test(s) failed.") + os.exit(1) +end diff --git a/tests/game3_mon_pic_center_test.lua b/tests/game3_mon_pic_center_test.lua new file mode 100644 index 00000000..14f363e7 --- /dev/null +++ b/tests/game3_mon_pic_center_test.lua @@ -0,0 +1,66 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local frames = {} +local draws = {} + +love = love or {} +love.graphics = love.graphics or {} +love.graphics.setColor = function() end +love.graphics.rectangle = function() end +love.graphics.draw = function(img, x, y) + draws[#draws + 1] = { img = img, x = x, y = y } +end + +package.loaded["src.ui.game3.chrome"] = { + stdFrame = function(tx, ty, tw, th) + frames[#frames + 1] = { tx, ty, tw, th } + end, +} +package.loaded["src.ui.game3.frlg_font"] = { COLOR = {} } + +local MonPic = require("src.ui.game3.mon_pic") + +local function run(x, y, ex, ey, epx, epy) + frames, draws = {}, {} + MonPic.active = true + MonPic.left, MonPic.top = x, y + MonPic._img = {} + MonPic._w, MonPic._h = 64, 64 + MonPic.draw() + local f = frames[1] + check(f ~= nil, ("showmonpic %d,%d draws a frame"):format(x, y)) + if not f then return end + check(f[1] == ex and f[2] == ey and f[3] == 8 and f[4] == 8, + ("frame content at (%d,%d) 8x8, got (%s,%s)"):format(ex, ey, tostring(f[1]), tostring(f[2]))) + local d = draws[1] + check(d ~= nil and d.x == epx and d.y == epy, + ("pic top-left at (%d,%d), got (%s,%s)"):format(epx, epy, tostring(d and d.x), tostring(d and d.y))) + if d then + local fcx, fcy = (f[1] + 4) * 8, (f[2] + 4) * 8 + check(d.x + 32 == fcx and d.y + 32 == fcy, + ("pic center (%d,%d) matches frame center (%d,%d)"):format(d.x + 32, d.y + 32, fcx, fcy)) + end +end + +print("[test] showmonpic window centered on its pic") +run(10, 3, 11, 4, 88, 32) +run(0, 0, 1, 1, 8, 8) + +if failed > 0 then + print(string.format("[FAIL] %d test(s) failed", failed)) + os.exit(1) +else + print("[test] all passed") +end diff --git a/tests/game3_summary_test.lua b/tests/game3_summary_test.lua index 4386f798..f7bdc344 100644 --- a/tests/game3_summary_test.lua +++ b/tests/game3_summary_test.lua @@ -6,7 +6,7 @@ -- 4. Page transitions (INFO -> SKILLS -> MOVES -> MOVE_DETAIL) -- 5. Modal Stack integration with PartyMenu -local Game3 = require("src.core.game3") +local Game3 = require("src.core.game3.init") local PartyMenu = require("src.ui.game3.party_menu") local SummaryMenu = require("src.ui.game3.summary_menu") local SummaryData = require("src.core.game3.summary_data") diff --git a/tests/game3_u10_intro_layer_test.lua b/tests/game3_u10_intro_layer_test.lua new file mode 100644 index 00000000..40f567fd --- /dev/null +++ b/tests/game3_u10_intro_layer_test.lua @@ -0,0 +1,98 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Ui = require("src.core.game3.battle.ui") +local Anim = require("src.core.game3.battle.anim") +local BattleBg = require("src.core.game3.battle.bg") +local Healthbox = require("src.core.game3.battle.healthbox") +local BattleChrome = require("src.ui.game3.battle_chrome") +local TrainerPic = require("src.core.game3.trainer_pic") +local Pokemon = require("src.core.game3.pokemon") + +local IMG = { + enemyMon = { tag = "enemyMon" }, + playerMon = { tag = "playerMon" }, + trainerBack = { tag = "trainerBack" }, + trainerFront = { tag = "trainerFront" }, +} + +local drawn = {} +local gfx = setmetatable({}, { __index = function() return function() end end }) +gfx.draw = function(img) drawn[#drawn + 1] = img and img.tag end +gfx.newQuad = function() return {} end +_G.love = { graphics = gfx } + +BattleBg.draw = function() return true end +Healthbox.draw = function() end +BattleChrome.drawPanel = function() end +BattleChrome.drawPartyBar = function() end +BattleChrome.manifest = function() return {} end +Anim.drawParticles = function() end +TrainerPic.front = function() return { image = IMG.trainerFront } end +TrainerPic.back = function() return { image = IMG.trainerBack } end +Pokemon.frontPic = function() return { image = IMG.enemyMon } end +Pokemon.backPic = function() return { image = IMG.playerMon } end + +local function indexOf(tag) + for i, t in ipairs(drawn) do + if t == tag then return i end + end + return nil +end + +print("[test] wild intro: enemy mon passes under player trainer back pic") +Ui.reset({ headless = false }) +Anim.reset({ headless = false }) +Ui._st = { enemy = { species = 95 }, player = { species = 4 } } +Anim.present("enemy").visible = true +Anim.present("enemy").ox = -48 +local stage = Anim.stage() +stage.trainer.player.visible = true +stage.trainer.player.ox = 48 +drawn = {} +Ui.draw(240, 160) +local iMon, iBack = indexOf("enemyMon"), indexOf("trainerBack") +check(iMon ~= nil and iBack ~= nil, "both enemy mon and trainer back drawn") +check(iMon and iBack and iMon < iBack, "enemy mon drawn before player trainer back") + +print("[test] trainer intro: enemy trainer front under player trainer back") +Ui.reset({ headless = false }) +Anim.reset({ headless = false }) +Ui._st = { enemy = { species = 7 }, player = { species = 4 } } +stage = Anim.stage() +stage.trainer.enemy.visible = true +stage.trainer.enemy.picId = 106 +stage.trainer.player.visible = true +drawn = {} +Ui.draw(240, 160) +local iFront = indexOf("trainerFront") +iBack = indexOf("trainerBack") +check(iFront and iBack and iFront < iBack, "enemy trainer front drawn before player trainer back") + +print("[test] send-out: player mon still above enemy mon") +Ui.reset({ headless = false }) +Anim.reset({ headless = false }) +Ui._st = { enemy = { species = 95 }, player = { species = 4 } } +Anim.present("enemy").visible = true +Anim.present("player").visible = true +drawn = {} +Ui.draw(240, 160) +iMon = indexOf("enemyMon") +local iPlayer = indexOf("playerMon") +check(iMon and iPlayer and iMon < iPlayer, "enemy mon drawn before player mon") + +if failed > 0 then + print("[test] FAILED " .. failed) + os.exit(1) +end +print("[test] all passed")