CLOSES #2130, CLOSES #2131, CLOSES #2132, CLOSES #2134, CLOSES #2135, CLOSES #2136, CLOSES #2137, CLOSES #2138

This commit is contained in:
bryanthaboi
2026-09-03 01:36:18 -04:00
parent 67ff7463ab
commit f00e867be8
34 changed files with 1588 additions and 145 deletions
+9 -6
View File
@@ -501,6 +501,11 @@ function Battle:monName(mon)
return mon.nickname or mon.name or mon.species or "?"
end
-- ../pokecrystal/data/text/battle.asm:240-246
function Battle.sentOutText(trainerName, monName)
return Strings("%s\nsent out\v%s!", trainerName, monName)
end
function Battle:moveDef(moveId)
return self.data.moves and self.data.moves[moveId] or nil
end
@@ -3554,8 +3559,7 @@ function Battle:resolveFaints()
replacement = true,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = Strings("%s sent out %s!",
self.trainer and self.trainer.name or "Foe",
text = Battle.sentOutText(self.trainer and self.trainer.name or "Foe",
self:monName(self.enemy)) })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
@@ -4546,8 +4550,7 @@ function Battle:switchEnemy(index)
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = Strings("%s sent out %s!", trainerName,
self:monName(self.enemy)) })
text = Battle.sentOutText(trainerName, self:monName(self.enemy)) })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
@@ -5313,8 +5316,8 @@ function Battle:forcedReplacement(side, index)
self:emit({ kind = "send", side = "enemy", mon = mon, replacement = true,
hp = mon.hp or 0, status = mon.status or false,
level = mon.level, experience = mon.experience,
text = Strings("%s sent out %s!",
(self.trainer and self.trainer.name) or "Foe", self:monName(mon)) })
text = Battle.sentOutText((self.trainer and self.trainer.name) or "Foe",
self:monName(mon)) })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(mon), battler = mon,
previous = previous,
+10 -6
View File
@@ -640,10 +640,19 @@ function TextBox:draw()
and (self.game.save.generation == 2 or self.game.save.version == "gold")
local Chrome = gold and Chrome2 or nil
local drawGlyph, finishGlyph = Font.drawCode, nil
-- pokegold home/joypad.asm:430
local arrowOn = self.blink % 60 < 30
if gold then arrowOn = self.blink % 32 < 16 end
local arrowX = (self.boxTx + self.boxTw - 2) * 8
local arrowY = (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4)
if Chrome then
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
or Chrome.DEFAULT_BOX_PALETTE
Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base)
if arrowOn and self:arrowVisible() then
-- ../pokecrystal/home/text.asm:630
Chrome.paletteFill(arrowX, arrowY, 8, 8, base)
end
local _, dg, fg = Chrome.paletteGlyphs(base)
drawGlyph, finishGlyph = dg, fg
else
@@ -697,16 +706,11 @@ function TextBox:draw()
pen = pen + Font.advanceOf(code)
end
end
-- pokegold home/joypad.asm:430
local arrowOn = self.blink % 60 < 30
if gold then arrowOn = self.blink % 32 < 16 end
if self:arrowVisible() and arrowOn then
-- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm)
-- pokegold home/text.asm:549
drawGlyph(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4))
drawGlyph(Theme.moreArrow or 0xEE, arrowX, arrowY)
end
if finishGlyph then finishGlyph() end
love.graphics.setColor(1, 1, 1, 1)
+16
View File
@@ -179,6 +179,22 @@ end
Movement.TELEPORT_RISE_HEIGHT = 0x10
Movement.TELEPORT_FALL_HEIGHT = 0
-- engine/overworld/map_objects.asm:1796-1817
Movement.JUMP_Y = {
-4, -6, -8, -10, -11, -12, -12, -12,
-11, -10, -9, -8, -6, -4, 0, 0,
}
function Movement.jumpYOffset(progress, frames)
local curve = Movement.JUMP_Y
local n = #curve
local t = ((progress or 0) - 1) * (n - 1) / math.max((frames or n) - 1, 1) + 1
local idx = math.floor(t)
if idx < 1 then idx = 1 end
if idx >= n then return curve[n] end
return math.floor(curve[idx] + (curve[idx + 1] - curve[idx]) * (t - idx) + 0.5)
end
local DIR_BYTE = { down = 0, up = 1, left = 2, right = 3 }
-- The `step <dir>` family ($0c-$0f), which is what InitMovementBuffer fills
+4
View File
@@ -1151,6 +1151,8 @@ local function runCmd(self, cmd, op)
return "end"
end
if self.reloadMapFn then self.reloadMapFn(op ~= "refreshmap") end
-- engine/overworld/scripting.asm:1209
if op ~= "refreshmap" then self:waitFrames(1) end
elseif op == "catchtutorial" then
-- `catchtutorial battle_type` runs the DUDE's catch demo
-- (engine/events/catch_tutorial.asm): the player's name is parked in
@@ -1182,6 +1184,8 @@ local function runCmd(self, cmd, op)
-- `jp Script_reloadmap`, so the setup script (and its ForceMapMusic row)
-- really does run here.
if self.reloadMapFn then self.reloadMapFn(true) end
-- engine/overworld/scripting.asm:1209
self:waitFrames(1)
elseif op == "winlosstext" then
-- Overrides the struct's win/loss text for this battle only; a 0
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
+54 -7
View File
@@ -228,10 +228,11 @@ local PAGE, SCROLL, LINE = "\f", "\v", "\n"
local SEPARATORS = "([^" .. LINE .. PAGE .. SCROLL .. "]*)([" ..
LINE .. PAGE .. SCROLL .. "])"
local function paginate(text)
local pages, rows = {}, {}
local pages, rows, carried = {}, {}, {}
local function flush(scroll)
if #rows > 0 then pages[#pages + 1] = table.concat(rows, LINE) end
rows = scroll and { rows[#rows] or "" } or {}
if scroll then carried[#pages + 1] = true end
end
for chunk, sep in (tostring(text or "") .. PAGE):gmatch(SEPARATORS) do
rows[#rows + 1] = chunk
@@ -239,7 +240,23 @@ local function paginate(text)
elseif sep == SCROLL then flush(true) end
end
if #pages == 0 then pages[1] = tostring(text or "") end
return pages
return pages, carried
end
-- ../pokecrystal/home/text.asm:517-526
local function foldPages(pages, carried)
local out, outCarried = {}, {}
for i, page in ipairs(pages) do
local lines = Chrome.wrap(page, TEXT_WIDTH)
out[#out + 1] = #lines > TEXT_ROWS
and table.concat(lines, LINE, 1, TEXT_ROWS) or page
outCarried[#out] = carried[i]
for j = TEXT_ROWS + 1, #lines do
out[#out + 1] = table.concat(lines, LINE, j - TEXT_ROWS + 1, j)
outCarried[#out] = true
end
end
return out, outCarried
end
function BattleState.fillScale(winW, winH)
@@ -496,7 +513,7 @@ function BattleState.new(game, opts)
-- (core.asm:2978-2980, 3354): this is where the mon's frontpic first
-- appears, where ANIM_SEND_OUT_MON plays and where the HUD comes up.
self:push({ kind = "send", side = "enemy", mon = enemy,
text = Strings("%s sent out %s!", trainerName, self:name(enemy)) })
text = Battle.sentOutText(trainerName, self:name(enemy)) })
end
end
local player = self.battle and self.battle.player
@@ -1671,6 +1688,12 @@ end
function BattleState:advanceQueue()
local event = table.remove(self.queue, 1)
self.messagePages, self.messageCarry = nil, nil
local pendingSend = self.pendingSendOut
if pendingSend then
self.pendingSendOut = nil
self:startSendOut(pendingSend.side, pendingSend.mon)
end
-- StartBattle runs `call z, UpdateEnemyHUD` AFTER BattleStartMessage returns,
-- and only for a wild battle (engine/battle/core.asm:7808-7817): the appeared
-- line is read against an empty HUD area and the bar comes up on the step
@@ -1995,7 +2018,7 @@ function BattleState:advanceQueue()
end
end
if event.text and not event.lineShown then
self.message = event.text
self:showPages(event.text)
self.typedText = nil
-- engine/battle/core.asm:8733
if self.startHuds then
@@ -2111,7 +2134,12 @@ function BattleState:advanceQueue()
-- player's voluntary switch through SendOutPlayerMon (:3796). Without it
-- the replacement simply appeared, which with two of a species back to back
-- reads as one mon growing a second health bar.
self:startSendOut(event.side, event.mon)
-- ../pokecrystal/engine/battle/core.asm:3146-3147
if self.messagePages then
self.pendingSendOut = { side = event.side, mon = event.mon }
else
self:startSendOut(event.side, event.mon)
end
end
end
@@ -3470,10 +3498,12 @@ end
-- One prompt page in the box, with the rest held for the presses `para` and
-- `cont` wait on (home/text.asm:403-448).
function BattleState:showPages(text)
local pages = paginate(text)
local pages, carried = foldPages(paginate(text))
self.messagePages = #pages > 1 and pages or nil
self.messageCarried = carried
self.messagePage = 1
self.message = pages[1]
self.messageCarry = nil
self.messageTimer = MESSAGE_FRAMES
end
@@ -3483,8 +3513,18 @@ function BattleState:nextPage()
local i = self.messagePage + 1
self.messagePage = i
self.message = pages[i]
self.messageCarry = self.messageCarried and self.messageCarried[i]
and pages[i] or nil
self.messageTimer = MESSAGE_FRAMES
if i >= #pages then self.messagePages = nil end
if i >= #pages then
self.messagePages = nil
-- ../pokecrystal/engine/battle/core.asm:3146-3147
local pending = self.pendingSendOut
if pending then
self.pendingSendOut = nil
self:startSendOut(pending.side, pending.mon)
end
end
return true
end
@@ -4209,7 +4249,14 @@ function BattleState:syncTyper()
for i = #lines, TEXT_ROWS + 1, -1 do lines[i] = nil end
self.typer = Typer.new(self.game)
self.typer:start(lines)
-- ../pokecrystal/home/text.asm:521-523
if self.messageCarry == text and lines[1] then
local carried = Typer.new(self.game)
carried:start({ lines[1] })
self.typer.shown = math.max(self.typer.shown, carried.total)
end
end
self.messageCarry = nil
end
return self.typer ~= nil and not self.typer:done()
end
+4 -2
View File
@@ -341,10 +341,12 @@ function Chrome.wrap(text, width)
end
-- Print wrapped text from (tx, ty) downward, at most `rows` lines.
function Chrome.printWrapped(text, tx, ty, width, rows)
-- ../pokecrystal/home/text.asm:473
function Chrome.printWrapped(text, tx, ty, width, rows, step)
step = step or 1
local lines = Chrome.wrap(text, width)
for i = 1, math.min(#lines, rows or #lines) do
Chrome.print(lines[i], tx, ty + i - 1)
Chrome.print(lines[i], tx, ty + (i - 1) * step)
end
return #lines
end
+2 -1
View File
@@ -111,7 +111,8 @@ function GenderSelect:drawPanel()
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
G.setColor(1, 1, 1, 1)
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1)
-- ../pokecrystal/home/text.asm:473
Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2)
Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H)
for i, option in ipairs(GenderSelect.OPTIONS) do
local row = TEXT_Y + (i - 1) * ROW_STEP
+29 -10
View File
@@ -67,8 +67,9 @@ local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
local DAYS = Clock.DAY_NAMES
InitClock.DAYS = DAYS
function InitClock:wantsFillScale() return true end
function InitClock:drawsWidescreen() return true end
-- ../pokecrystal/engine/rtc/timeset.asm:385
function InitClock:wantsFillScale() return self.mode ~= "day" end
function InitClock:drawsWidescreen() return self.mode ~= "day" end
-- PrintHour (engine/rtc/timeset.asm:672) is GetTimeOfDayString + PlaceString,
-- then AdjustHourForAMorPM as a left-aligned two-digit number. So the cart
@@ -123,6 +124,8 @@ function InitClock.new(game, opts)
local self = setmetatable({}, InitClock)
self.game = game
self.mode = opts.mode == "day" and "day" or "clock"
-- ../pokecrystal/engine/rtc/timeset.asm:385
self.isOpaque = self.mode ~= "day"
self.save = opts.save or (game and game.save)
self.onDone = opts.onDone
self.autoConfirm = opts.autoConfirm or false
@@ -144,11 +147,24 @@ end
-- The current question, split into its pages.
function InitClock:pages()
local question = self:question()
if self.pagesText == question and self.pagesCache then
return self.pagesCache
end
local out = {}
for page in (self:question() .. "\f"):gmatch("(.-)\f") do
if page ~= "" then out[#out + 1] = page end
for page in (question .. "\f"):gmatch("(.-)\f") do
if page ~= "" then
local lines = {}
for line in (page .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = line end
-- ../pokecrystal/home/text.asm:502
out[#out + 1] = table.concat({ lines[1], lines[2] }, "\n")
for i = 3, #lines do
out[#out + 1] = table.concat({ lines[i - 1], lines[i] }, "\n")
end
end
end
if #out == 0 then out[1] = "" end
self.pagesText, self.pagesCache = question, out
return out
end
@@ -329,7 +345,8 @@ local function arrow(tx, ty, up)
end
function InitClock:drawPanel()
Chrome.clear()
-- ../pokecrystal/engine/rtc/timeset.asm:22
if self.mode ~= "day" then Chrome.clear() end
local value = self:display()
if value then
local bx, by, bw, bh, arrowX, tx, ty = self:pickerBox()
@@ -343,12 +360,14 @@ function InitClock:drawPanel()
-- The question (and the confirmations) share the bottom textbox every other
-- Gold prompt uses.
Chrome.textbox(0, 12, 18, 4)
Chrome.printWrapped(self:pageText(), 1, 14, 18, 3)
-- ../pokecrystal/home/text.asm:473
Chrome.printWrapped(self:pageText(), 1, 14, 18, 2, 2)
if self:confirming() then
Chrome.box(14, 6, 6, 5)
Chrome.print(Strings("YES"), 16, 7)
Chrome.print(Strings("NO"), 16, 9)
Chrome.cursor(15, self.yesNo == 1 and 7 or 9)
-- ../pokecrystal/home/menu.asm:418
Chrome.box(14, 7, 6, 5)
Chrome.print(Strings("YES"), 16, 8)
Chrome.print(Strings("NO"), 16, 10)
Chrome.cursor(15, self.yesNo == 1 and 8 or 10)
end
end
+54 -15
View File
@@ -29,6 +29,7 @@ local SaveMenu = require("src.ui.gen2.SaveMenu")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Typer = require("src.ui.gen2.Typer")
-- _PCMonHoldingMailText (data/text/common_2.asm), the refusal
-- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because
@@ -202,9 +203,8 @@ end
-- engine/menus/save.asm:40 ChangeBoxSaveGame
function PcMenu:beginChangeBox(index)
self.changeBox = index
self.savePhase = "confirm"
self:setSavePhase("confirm")
self.saveChoice = 1
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists() end
@@ -217,15 +217,20 @@ function PcMenu:refuseChangeBox()
self.saveTimer = 0
end
function PcMenu:setSavePhase(phase)
self.savePhase = phase
self.savePage = 1
self.saveTimer = 0
end
function PcMenu:acceptChangeBox()
if self.saveChoice == 2 then return self:refuseChangeBox() end
if self.savePhase == "confirm" and self.existed then
self.savePhase = "overwrite"
self:setSavePhase("overwrite")
self.saveChoice = 1
return
end
self.savePhase = "saving"
self.saveTimer = 0
self:setSavePhase("saving")
end
-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and
@@ -241,32 +246,54 @@ function PcMenu:writeChangeBox()
end
end
function PcMenu:savePrompt()
function PcMenu:savePromptText()
if self.savePhase == "overwrite" then
return SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))
return Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE)
end
if self.savePhase == "saving" then
return SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))
return Strings(SaveMenu.SAVING_PROMPT_SOURCE)
end
if self.savePhase == "done" then
if self.saved then
local name = (self.save.player and self.save.player.name) or "GOLD"
return SaveMenu.twoLines(Strings("%s saved\nthe game.", name))
return Strings("%s saved\nthe game.", name)
end
return SaveMenu.twoLines(Strings("Could not save."))
return Strings("Could not save.")
end
return SaveMenu.twoLines(Strings(CHANGE_BOX_SAVE_SOURCE))
return Strings(CHANGE_BOX_SAVE_SOURCE)
end
function PcMenu:savePages()
local text = self:savePromptText()
if self.savePagesText ~= text or not self.savePagesCache then
self.savePagesText = text
self.savePagesCache = SaveMenu.pagesOf(text)
end
return self.savePagesCache
end
function PcMenu:savePrompt()
local pages = self:savePages()
return pages[self.savePage or 1] or pages[1]
end
-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno
function PcMenu:saveYesNoVisible(pages)
if self.savePhase ~= "confirm" and self.savePhase ~= "overwrite" then
return false
end
return (self.savePage or 1) >= #(pages or self:savePages())
end
function PcMenu:updateChangeBox()
Typer.step(self)
-- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does
-- anything until the sequence runs out (engine/menus/save.asm:55).
if self.savePhase == "saving" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
self:writeChangeBox()
self.savePhase = "done"
self.saveTimer = 0
self:setSavePhase("done")
end
return
end
@@ -281,6 +308,14 @@ function PcMenu:updateChangeBox()
local input = self.game and self.game.input
if not input then return end
-- ../pokecrystal/home/text.asm:502 _ContText
if not self:saveYesNoVisible() then
if input:wasPressed("a") or input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self.savePage = (self.savePage or 1) + 1
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.saveChoice = self.saveChoice == 1 and 2 or 1
elseif input:wasPressed("a") then
@@ -442,10 +477,14 @@ function PcMenu:drawPanel()
if self.savePhase then
-- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list.
Chrome.box(0, 12, 20, 6)
local lines = self:savePrompt()
local pages = self:savePages()
local lines = pages[self.savePage or 1] or pages[1]
Chrome.print(lines[1] or "", 1, 14)
Chrome.print(lines[2] or "", 1, 16)
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
if (self.savePage or 1) < #pages and Typer.arrowOn(self) then
Chrome.print(SaveMenu.DOWN_ARROW, SaveMenu.ARROW_X, SaveMenu.ARROW_Y)
end
if self:saveYesNoVisible(pages) then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3)
+80 -29
View File
@@ -24,7 +24,6 @@
-- one-line call.
local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger")
local Save = require("src.core.gen2.Save")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
@@ -64,7 +63,7 @@ local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
-- SavingDontTurnOffThePower's own line -- one \n-joined translatable key
-- each, used both by this screen's own prompt() below and, through the
-- SOURCE/twoLines() exports at the bottom of this file, by the PC's CHANGE
-- SOURCE/pagesOf() exports at the bottom of this file, by the PC's CHANGE
-- BOX save (src/ui/gen2/PcMenu.lua:savePrompt()), which shares these exact
-- same two cart messages. One key per prompt lets a translation write one
-- whole, freely reordered sentence instead of two fragments translated in
@@ -77,27 +76,46 @@ local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- string harvester only recognizes a literal inside Strings.source(...),
-- not a computed expression, so a concat call here would quietly never
-- reach a translator.
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it")
-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it\vOK to overwrite?")
local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
-- Splits a translated "line one\nline two" string back into the two-slot
-- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a
-- single-line message, or German's one-line SAVING prompt) lands whole on
-- the first slot, matching the untranslated code's own { text, "" } shape.
--
-- Only the first "\n" splits, since this box has room for exactly two
-- lines. A third line would otherwise draw as a raw newline byte -- garbage
-- glyph data -- with no other sign anything went wrong, so this warns once
-- per string instead.
local warnedTooManyLines = {}
local function twoLines(text)
local first, second = text:match("^(.-)\n(.*)$")
if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then
warnedTooManyLines[text] = true
Logger.warn("SaveMenu: translation of %q has more than two lines; " ..
"only the first two fit this box", text)
-- ../pokecrystal/home/text.asm:630 LoadBlinkingCursor
local DOWN_ARROW = "\xe2\x96\xbc"
local ARROW_X, ARROW_Y = 18, 17
-- ../pokecrystal/home/text.asm:479 Paragraph
-- ../pokecrystal/home/text.asm:520 _ContTextNoPause
local function pagesOf(body)
local pages = {}
for chunk in (tostring(body) .. "\f"):gmatch("(.-)\f") do
local flat, pos, scrolled = {}, 1, false
while true do
local brk = chunk:find("[\n\v]", pos)
local line = brk and chunk:sub(pos, brk - 1) or chunk:sub(pos)
if line ~= "" then flat[#flat + 1] = { line, scrolled } end
if not brk then break end
scrolled = chunk:sub(brk, brk) == "\v"
pos = brk + 1
end
local page
for _, entry in ipairs(flat) do
if not page then
page = { entry[1] }
pages[#pages + 1] = page
elseif entry[2] then
page = { page[#page], entry[1] }
pages[#pages + 1] = page
elseif #page >= 2 then
page = { entry[1] }
pages[#pages + 1] = page
else
page[#page + 1] = entry[1]
end
end
end
return { first or text, second or "" }
if #pages == 0 then pages[1] = { "" } end
return pages
end
function SaveMenu:wantsFillScale() return true end
@@ -132,6 +150,13 @@ function SaveMenu:playSfx(id)
SaveMenu.playSaveSfx(self.game, id)
end
-- ../pokecrystal/home/joypad.asm:392
function SaveMenu:playSfxNamed(name)
local data = self.game and self.game.data
local sfx = data and data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
end
function SaveMenu:finish(saved)
if self.onDone then self.onDone(saved) end
end
@@ -152,9 +177,11 @@ function SaveMenu:enterPhase(phase)
self.phase = phase
self.timer = 0
self.typedPhase = phase
self.pages = pagesOf(self:promptText())
self.page = 1
local pinned = (phase == "saving" or phase == "done") and "MID" or nil
self.typer = Typer.new(self.game, { speed = pinned })
self.typer:start(self:prompt())
self.typer:start(self.pages[1])
end
function SaveMenu:accept()
@@ -215,6 +242,15 @@ function SaveMenu:update(_dt)
local input = self.game and self.game.input
if not input then return end
if Typer.typing(self) then return end
-- ../pokecrystal/home/text.asm:502 _ContText
if self.page < #self.pages then
if input:wasPressed("a") or input:wasPressed("b") then
self.page = self.page + 1
self.typer:start(self.pages[self.page])
self:playSfxNamed("Sfx_ReadText2")
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.choice = self.choice == 1 and 2 or 1
return
@@ -230,28 +266,36 @@ end
-- The two lines the speech box holds, in the cart's own wording. `line` puts
-- the second one on the box's lower line; `cont` scrolls, which the overwrite
-- prompt uses for its third line and which this shows as a second page.
function SaveMenu:prompt()
function SaveMenu:promptText()
if self.phase == "overwrite" then
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
-- when the ID differs. Only the first can happen here.
return twoLines(Strings(OVERWRITE_PROMPT_SOURCE))
return Strings(OVERWRITE_PROMPT_SOURCE)
end
if self.phase == "saving" then
return twoLines(Strings(SAVING_PROMPT_SOURCE))
return Strings(SAVING_PROMPT_SOURCE)
end
if self.phase == "done" then
if self.saved then
return twoLines(Strings("%s saved\nthe game.", self:playerName()))
return Strings("%s saved\nthe game.", self:playerName())
end
return twoLines(Strings("Could not save."))
return Strings("Could not save.")
end
return twoLines(Strings("Would you like to\nsave the game?"))
return Strings("Would you like to\nsave the game?")
end
function SaveMenu:prompt()
if self.typedPhase == self.phase and self.pages then
return self.pages[self.page] or self.pages[1]
end
return pagesOf(self:promptText())[1]
end
-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno
function SaveMenu:yesNoVisible()
if self.phase ~= "confirm" and self.phase ~= "overwrite" then return false end
return self.typedPhase == self.phase and not Typer.typing(self)
if self.typedPhase ~= self.phase or Typer.typing(self) then return false end
return self.page >= #self.pages
end
function SaveMenu:drawPanel()
@@ -278,6 +322,11 @@ function SaveMenu:drawPanel()
Chrome.print(lines[1] or "", 1, 14)
Chrome.print(lines[2] or "", 1, 16)
if self.typedPhase == self.phase and not Typer.typing(self)
and self.page < #self.pages and Typer.arrowOn(self) then
Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y)
end
if self:yesNoVisible() then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
@@ -307,6 +356,8 @@ SaveMenu.SAVED_TAIL_FRAMES = SAVED_TAIL_FRAMES
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
SaveMenu.OVERWRITE_PROMPT_SOURCE = OVERWRITE_PROMPT_SOURCE
SaveMenu.SAVING_PROMPT_SOURCE = SAVING_PROMPT_SOURCE
SaveMenu.twoLines = twoLines
SaveMenu.pagesOf = pagesOf
SaveMenu.DOWN_ARROW = DOWN_ARROW
SaveMenu.ARROW_X, SaveMenu.ARROW_Y = ARROW_X, ARROW_Y
return SaveMenu
+30 -1
View File
@@ -359,6 +359,26 @@ function NPC:scriptStep(dir)
return true
end
-- engine/overworld/movement.asm:741
function NPC:scriptJump(dir)
if self.moving then return false end
self.stepDir = dir or self.facing
if not self.fixedFacing and not self.sliding then
self.facing = self.stepDir
end
local d = Map.DELTA[self.stepDir]
if not d then
self.stepDir = nil
return false
end
self.targetX, self.targetY = self.cellX + d[1] * 2, self.cellY + d[2] * 2
self.moving, self.jumping = true, true
self.inGrass, self.grassShake = false, nil
self.progress = 0
self.frozen = true
return true
end
-- StepFunction_TeleportFrom / _TeleportTo (engine/overworld/map_objects.asm).
-- `from` is sixteen frames of OBJECT_ACTION_SPIN on the spot and then sixteen
-- more spinning while OBJECT_JUMP_HEIGHT walks OBJECT_SPRITE_Y_OFFSET up a
@@ -575,7 +595,7 @@ function NPC:update(map, entities)
if self.moving then
-- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674);
-- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226).
if self.progress == 0 and map then
if self.progress == 0 and map and not self.jumping then
local grass = NPC.grassAt(map, self.targetX, self.targetY)
if self.inGrass then self.inGrass = grass end
self.grassShake = grass or nil
@@ -585,17 +605,26 @@ function NPC:update(map, entities)
-- a two-cell move over one step (src/world/gen2/Player.lua:150 does the
-- same), and `stepFrames` is what lets it keep pace with a bike.
local frames = self.stepFrames or STEP_FRAMES
-- engine/overworld/map_objects.asm:1129
if self.jumping then frames = STEP_FRAMES * 2 end
local moved = math.floor(self.progress * 16 / frames)
local dx = (self.targetX or self.cellX) - self.cellX
local dy = (self.targetY or self.cellY) - self.cellY
self.px = self.cellX * 16 + dx * moved
self.py = self.cellY * 16 + dy * moved
if self.jumping then
self.spriteYOffset = Movement.jumpYOffset(self.progress, frames)
end
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.stepDir = nil
if self.jumping then
self.jumping = nil
self.spriteYOffset = 0
end
self.stepFlip = not self.stepFlip
-- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags at the step's end
-- (map_objects.asm:196-208, :247).
+20 -18
View File
@@ -2,6 +2,7 @@
-- Draws via shared SpriteRenderer (same 16x96 facing layout as Gen 1).
local Map = require("src.world.gen2.Map")
local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
@@ -17,12 +18,6 @@ local TURN_FRAMES = 4
-- at the walking rate, which is what stops a bike step flickering the legs.
Player.STEP_FRAMES = STEP_FRAMES
-- engine/overworld/map_objects.asm:1815
local JUMP_Y = {
-4, -6, -8, -10, -11, -12, -12, -12,
-11, -10, -9, -8, -6, -4, 0, 0,
}
-- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which
-- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152).
local ROD_OAM = {
@@ -149,8 +144,9 @@ function Player:scriptStep(dir)
-- A scripted step names its own STEP_* on the cart (SurfStartStep is a slow
-- step), so it never inherits the bike's shorter one.
self.stepFrames = STEP_FRAMES
self.facing = dir or self.facing
local d = Map.DELTA[self.facing]
-- engine/overworld/map_objects.asm:284-294
if not self.fixedFacing then self.facing = dir or self.facing end
local d = Map.DELTA[dir or self.facing]
if not d then return false end
self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2]
self.moving = true
@@ -159,6 +155,21 @@ function Player:scriptStep(dir)
return true
end
-- engine/overworld/movement.asm:741
function Player:scriptJump(dir)
if self.moving then return false end
if not self.fixedFacing then self.facing = dir or self.facing end
local d = Map.DELTA[dir or self.facing]
if not d then return false end
self.targetX, self.targetY = self.cellX + d[1] * 2, self.cellY + d[2] * 2
self.moving, self.jumping = true, true
self.bumpFrames = nil
self.inGrass, self.grassShake = false, nil
self.progress = 0
self.stepFrames = STEP_FRAMES * 2
return true
end
-- CounterclockwiseSpinAction's .facings, seeded from the current direction by
-- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116
local SPIN_FACINGS = { "down", "right", "up", "left" }
@@ -235,16 +246,7 @@ function Player:update()
if self.jumping then
-- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame,
-- tweened across our doubled step (#1713)
local t = (self.progress - 1) * (#JUMP_Y - 1)
/ math.max(frames - 1, 1) + 1
local idx = math.floor(t)
if idx < 1 then idx = 1 end
if idx >= #JUMP_Y then
self.spriteYOffset = JUMP_Y[#JUMP_Y]
else
self.spriteYOffset = math.floor(
JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5)
end
self.spriteYOffset = Movement.jumpYOffset(self.progress, frames)
end
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
+18 -5
View File
@@ -1941,6 +1941,11 @@ end
-- command -- unlike a plain `setevent`, which the cart only reads back on the
-- next map load.
function World:appearObject(objectId)
-- home/map_objects.asm:309
if objectId == 0 then
self.playerMasked = nil
return
end
local index = (objectId or 0) - 1
local def = self.map and self.map.def
local obj = def and def.objects and def.objects[index]
@@ -3522,6 +3527,8 @@ function World:runMapSetup(method, load, fly)
self:roamMonsBeforeLoad(method)
local wrapped = function()
local ok = load()
-- engine/overworld/map_objects_2.asm:1
self.playerMasked = nil
self:roamMonsAfterLoad(method)
return ok
end
@@ -3893,6 +3900,11 @@ function World:turnObject(objectId, facing)
end
function World:disappearObject(objectId)
-- home/map_objects.asm:317
if objectId == 0 then
self.playerMasked = true
return
end
local index = (objectId or 0) - 1
local def = self.map and self.map.def
local obj = def and def.objects and def.objects[index]
@@ -4021,9 +4033,7 @@ function World:updateMovement()
return
end
if ent.moving then return end
-- StepFunction_NPCJump's `.Land` beat (engine/overworld/map_objects.asm:1150):
-- `.Jump` already walked one cell and ran GetNextTile again, so the second
-- cell belongs to the jump and not to the next movement byte.
-- engine/overworld/map_objects.asm:1150
if st.pendingStep then
local dir = st.pendingStep
st.pendingStep = nil
@@ -4083,7 +4093,9 @@ function World:updateMovement()
-- GetNextTile only record the tile for the grass flag and never block
-- scripted movement.
local fromX, fromY = ent.cellX, ent.cellY
if ent:scriptStep(act.dir) then
if ent.scriptJump and ent:scriptJump(act.dir) then
self:followStep(ent, fromX, fromY)
elseif not ent.scriptJump and ent:scriptStep(act.dir) then
st.pendingStep = act.dir
self:followStep(ent, fromX, fromY)
end
@@ -9721,6 +9733,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- A follow pairing points at two live objects, and a map load rebuilds them
-- (RefreshMapSprites); nothing on the cart survives that either.
self.followState = nil
self.playerMasked = nil
-- EnterMap's SetUpFiveStepWildEncounterCooldown (engine/overworld/events.asm:
-- 110, :367-370): four encounter-free steps after every map entry.
self.wildCooldown = 5
@@ -11102,7 +11115,7 @@ function World:drawPeople(s, billboard)
local filter = self.spriteFilter
local drawList = {}
if not hideAll then
if not hidePlayer then
if not hidePlayer and not self.playerMasked then
drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 }
end
for _, npc in ipairs(self.npcs) do
@@ -0,0 +1,75 @@
local U = require("tests.drivers.util")
-- ../pokecrystal/engine/rtc/timeset.asm:385
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
return function(game)
local fails = 0
local function say(line) print("[2138] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function dayWheel()
local top = game.stack and game.stack:top()
if top and top.mode == "day" and top.pickerBox then return top end
return nil
end
U.wait(60)
local world = game.world
if not (world and world.map) then
say("FAIL the gen 2 world did not boot")
love.event.quit(1)
return
end
-- ../pokecrystal/maps/PlayersHouse1F.asm:395
world:warpToMapId("PLAYERS_HOUSE_1F", 9, 1, "down")
U.wait(30)
ok(world.map and world.map.id == "PLAYERS_HOUSE_1F", "standing at the top of the stairs")
for _ = 1, 3 do tap("down", 10) end
U.wait(30)
U.shot(game, SHOT_DIR .. "/2138_scene_start.png")
local wheel
for _ = 1, 120 do
wheel = dayWheel()
if wheel then break end
tap("a", 6)
end
if not wheel then
say("the Mom scene did not reach SetDayOfWeek; pushing the wheel directly")
world:setDayOfWeek(function() end)
U.wait(10)
wheel = dayWheel()
end
ok(wheel ~= nil, "the day-of-week wheel is up")
if wheel then
ok(wheel.isOpaque == false, "and it is an overlay, not an opaque page")
ok(not wheel:drawsWidescreen(), "with no widescreen surround of its own")
end
U.wait(10)
U.shot(game, SHOT_DIR .. "/2138_day_wheel.png")
say("eyeball 2138_day_wheel.png: the living room, Mom and the player stay "
.. "visible around the SUNDAY picker and the question box")
tap("a", 20)
U.shot(game, SHOT_DIR .. "/2138_confirm.png")
say("eyeball 2138_confirm.png: YES/NO box at (14,7), YES on row 8, NO on row 10")
tap("a", 30)
ok(dayWheel() == nil, "YES pops the wheel")
U.shot(game, SHOT_DIR .. "/2138_after.png")
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,93 @@
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
local EVENT_LANCES_ROOM_ENTRANCE_CLOSED = 785
local EVENT_LANCES_ROOM_OAK_AND_MARY = 1887
return function(game)
local fails = 0
local function say(line) print("[2135] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map and world.vm and game.save) then
say("FAIL the gen 2 world did not boot")
love.event.quit(1)
return
end
game.save.party = { Mon.new(game.data, "TYPHLOSION", 100) }
world.events:set(EVENT_LANCES_ROOM_ENTRANCE_CLOSED, true)
world.events:set(EVENT_LANCES_ROOM_OAK_AND_MARY, true)
world.mapScenes = world.mapScenes or {}
world.mapScenes.LANCES_ROOM = 1
world:warpToMapId("LANCES_ROOM", 4, 6, "up")
for _ = 1, 120 do if not world:busy() then break end U.wait(1) end
U.wait(10)
ok(world.map and world.map.id == "LANCES_ROOM", "standing in Lance's room")
U.hold(game, "up", 8)
for _ = 1, 120 do
if world.vm:running() then break end
U.wait(1)
end
ok(world.vm:running(), "the (4,5) coord event started the approach script")
local screen
for _ = 1, 1200 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.tap(game, "a")
U.wait(2)
end
ok(screen ~= nil, "the Lance battle opened")
if not screen then
love.event.quit(1)
return
end
for _ = 1, 6000 do
if game.stack:top() ~= screen then break end
for _, m in ipairs(screen.battle.enemyParty or {}) do m.hp = 0 end
if screen.battle.enemy then screen.battle.enemy.hp = 0 end
U.tap(game, "a"); U.wait(2)
end
ok(game.stack:top() ~= screen, "Lance went down")
local masked, maskedShot, unmaskedInHof, shots = false, false, false, 0
local frames = 0
for _ = 1, 6000 do
frames = frames + 1
if world.map and world.map.id == "LANCES_ROOM" and world.playerMasked then
masked = true
if not maskedShot and not world.mapSetup then
maskedShot = true
U.shot(game, SHOT_DIR .. "/crystal_bug2135_01_player_gone.png")
say("the shot must show an EMPTY doorway while Mary runs up")
end
end
if world.map and world.map.id == "HALL_OF_FAME" then
if not world.mapSetup then
unmaskedInHof = not world.playerMasked
U.wait(30)
U.shot(game, SHOT_DIR .. "/crystal_bug2135_02_hof.png")
break
end
elseif masked and shots < 3 and frames % 30 == 0 then
shots = shots + 1
U.shot(game, string.format("%s/crystal_bug2135_mary_%d.png", SHOT_DIR, shots))
end
if frames % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
ok(masked, "disappear PLAYER masked the player before the warp")
ok(world.map and world.map.id == "HALL_OF_FAME", "warpfacing reached HALL_OF_FAME")
ok(unmaskedInHof, "and the load respawned the player there")
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,97 @@
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
return function(game)
local fails = 0
local function say(line) print("[2130] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map and world.vm and game.save) then
say("FAIL the crystal world did not boot")
love.event.quit(1)
return
end
game.save.party = { Mon.new(game.data, "TYPHLOSION", 90) }
world.mapScenes = world.mapScenes or {}
world.mapScenes.WILLS_ROOM = 1
world:warpToMapId("WILLS_ROOM", 5, 8, "up")
for _ = 1, 120 do if not world:busy() then break end U.wait(1) end
U.wait(10)
ok(world.map and world.map.id == "WILLS_ROOM", "standing in Will's room")
U.shot(game, SHOT_DIR .. "/crystal_bug2130_00_room.png")
local started = world.vm:start({
{ op = "loadtrainer", class = 11, member = 1 },
{ op = "startbattle" },
{ op = "reloadmapafterbattle" },
{ op = "opentext" },
{ op = "writetext", text = "60:4644" },
{ op = "waitbutton" },
{ op = "closetext" },
{ op = "end" },
})
ok(started, "the battle-then-text script started")
local screen
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
ok(screen ~= nil, "the trainer battle opened")
if not screen then
love.event.quit(1)
return
end
for _ = 1, 3000 do
if game.stack:top() ~= screen then break end
for _, m in ipairs(screen.battle.enemyParty or {}) do m.hp = 0 end
if screen.battle.enemy then screen.battle.enemy.hp = 0 end
U.tap(game, "a"); U.wait(2)
end
ok(game.stack:top() ~= screen, "the battle screen popped")
say("battle popped at frame " .. U.frame() .. " fade=" .. tostring(world.fade)
.. " mapSetup=" .. tostring(world.mapSetup and world.mapSetup.phase))
local sawFade, boxFrame, boxFade, boxSetup = false, nil, nil, nil
for _ = 1, 240 do
if world.mapSetup then sawFade = true end
local top = game.stack:top()
if world.textbox or (top and top.text and not top.battle) then
boxFrame = U.frame()
boxFade, boxSetup = world.fade, world.mapSetup and world.mapSetup.phase
break
end
U.wait(1)
end
ok(sawFade, "FadeInFromWhite ran after the battle")
ok(boxFrame ~= nil, "the after-battle text box opened")
ok(boxFade == nil, "no white sheet under the text box (fade="
.. tostring(boxFade) .. ")")
ok(boxSetup == nil, "the map setup chain finished before the box (phase="
.. tostring(boxSetup) .. ")")
U.wait(4)
U.shot(game, SHOT_DIR .. "/crystal_bug2130_01_text.png")
say("the shot must show Will's room and both sprites behind the text box")
U.wait(20)
U.shot(game, SHOT_DIR .. "/crystal_bug2130_02_text.png")
for _ = 1, 300 do
if not world.vm:running() then break end
U.tap(game, "a")
U.wait(4)
end
ok(not world.vm:running(), "the script ran to completion")
U.shot(game, SHOT_DIR .. "/crystal_bug2130_03_end.png")
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,83 @@
local U = require("tests.drivers.util")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
local EVENT_RIVAL_NEW_BARK_TOWN = 1725
return function(game)
local fails = 0
local function say(line) print("[2136] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map and world.vm and game.save) then
say("FAIL the gen 2 world did not boot")
love.event.quit(1)
return
end
world.events:set(EVENT_RIVAL_NEW_BARK_TOWN, false)
world:warpToMapId("NEW_BARK_TOWN", 3, 3, "up")
for _ = 1, 120 do if not world:busy() then break end U.wait(1) end
U.wait(10)
ok(world.map and world.map.id == "NEW_BARK_TOWN", "standing under the rival")
local rival
for _, npc in ipairs(world.npcs or {}) do
if npc.cellX == 3 and npc.cellY == 2 then rival = npc end
end
ok(rival ~= nil, "the rival is at (3,2)")
U.shot(game, SHOT_DIR .. "/crystal_bug2136_00_before.png")
U.tap(game, "a")
for _ = 1, 60 do
if world.vm:running() then break end
U.wait(1)
end
ok(world.vm:running(), "talking to the rival started NewBarkTownRivalScript")
local p = world.player
local lowest, jumpFrames, shot, pushedTo = 0, 0, false, nil
local facingDuringJump
for _ = 1, 1800 do
if not world.vm:running() then break end
if p.jumping then
jumpFrames = jumpFrames + 1
lowest = math.min(lowest, p.spriteYOffset or 0)
facingDuringJump = facingDuringJump or p.facing
if not shot and (p.spriteYOffset or 0) <= -11 then
shot = true
U.shot(game, SHOT_DIR .. "/crystal_bug2136_01_airborne.png")
say("the shot must show the player lifted off the ground with a shadow under them")
end
elseif p.moving and not pushedTo then
pushedTo = p.targetY
end
local top = game.stack:top()
if (world.textbox or (top and top.text)) and jumpFrames == 0 then
U.tap(game, "a")
U.wait(3)
else
U.wait(1)
end
end
ok(not world.vm:running(), "the rival script ran to completion")
ok(pushedTo == 4, "the push walks the player one cell down (target="
.. tostring(pushedTo) .. ")")
ok(jumpFrames > 0, "jump_step DOWN set the player jumping")
ok(jumpFrames >= 28 and jumpFrames <= 34,
"for one doubled step (" .. jumpFrames .. " frames)")
ok(lowest == -12, "the arc peaks twelve pixels up (lowest=" .. lowest .. ")")
ok(facingDuringJump == "up", "under fix_facing the player keeps facing up (facing="
.. tostring(facingDuringJump) .. ")")
ok(p.cellX == 3 and p.cellY == 6, "and lands at (3,6) (at "
.. p.cellX .. "," .. p.cellY .. ")")
ok(p.spriteYOffset == 0 and not p.jumping, "on the ground again")
U.shot(game, SHOT_DIR .. "/crystal_bug2136_02_landed.png")
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,116 @@
-- POKEPORT_IDENTITY=crystal-2134 POKEPORT_VERSION=crystal \
-- POKEPORT_DRIVER=tests/drivers/crystal_sendout_name_bug2134.lua \
-- POKEPORT_SHOT_DIR=/tmp/c2134 love .
-- ../pokecrystal/data/text/battle.asm:240-246
-- ../pokecrystal/engine/battle/core.asm:3146-3147
-- ../pokecrystal/home/text.asm:502-526
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/c2134"
local fails = 0
local function ok(cond, line)
if not cond then fails = fails + 1 end
print("[sendout-2134] " .. (cond and "PASS " or "FAIL ") .. line)
end
local function lines(screen)
return table.concat(screen:messageLines(), "|")
end
local function sendStarted(screen)
return screen.afterSendOut ~= nil or screen.showEnemyHud == true
end
U.wait(45)
local world = game.world
assert(world and world.map, "the crystal world did not boot")
local lead = Mon.new(game.data, "DRAGONITE", 47)
assert(lead and #lead.moves > 0, "could not build a DRAGONITE")
for i, move in ipairs(lead.moves) do
local def = game.data.moves[move.id]
if def and (def.power or 0) > 0 then
table.remove(lead.moves, i)
table.insert(lead.moves, 1, move)
break
end
end
game.save.party = { lead }
game.save.inventory = { POKE_BALL = 5 }
local trainer = {
class = 16, classId = "CHAMPION", memberId = "LANCE",
name = "CHAMPION LANCE", trainerName = "LANCE", className = "CHAMPION",
party = { Mon.new(game.data, "GYARADOS", 44),
Mon.new(game.data, "DRAGONITE", 61) },
baseMoney = 25,
}
assert(world:startBattle({ trainer = trainer }), "startBattle failed")
local screen
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
assert(screen and screen.battle, "battle screen never came up")
local function settle()
for _ = 1, 600 do
if screen.typer and screen.typer:done() then return true end
U.wait(1)
end
return false
end
local function awaitSendOut(name, tag)
for _ = 1, 2400 do
local m = screen.message
if m and m:find("sent out", 1, true) and screen.messagePages then
settle()
local first = lines(screen)
ok(first == "CHAMPION LANCE|sent out",
tag .. " page 1 reads CHAMPION LANCE / sent out: " .. first)
ok(not sendStarted(screen) and screen.pendingSendOut ~= nil,
tag .. " the ball is still shut on page 1")
U.shot(game, out .. "/" .. tag .. "-page1.png")
U.tap(game, "a")
U.wait(2)
local early = screen:messageLines()
ok(early[1] == "sent out",
tag .. " 'sent out' is already on row 1 of page 2")
settle()
local second = lines(screen)
ok(second == "sent out|" .. name .. "!",
tag .. " page 2 reads sent out / " .. name .. "!: " .. second)
ok(sendStarted(screen), tag .. " ANIM_SEND_OUT_MON runs with page 2")
U.shot(game, out .. "/" .. tag .. "-page2.png")
return true
end
if screen.phase == "menu" then
if screen.battle.enemyIndex == 1
and (screen.battle.enemy.hp or 0) > 1 then
screen.battle.enemy.hp = 1
screen.battle.enemy.moves = {}
screen.battle.enemy.status = false
end
U.tap(game, "a")
U.wait(2)
U.tap(game, "a")
U.wait(2)
elseif (screen.messageTimer or 0) > 0 and screen.typer
and screen.typer:done() then
U.tap(game, "a")
end
U.wait(1)
end
ok(false, tag .. " send-out line never came up")
return false
end
awaitSendOut("GYARADOS", "intro")
awaitSendOut("DRAGONITE", "replacement")
print("[sendout-2134] " .. (fails == 0 and "PASS all claims" or
(fails .. " claims failed")) .. " -- shots in " .. out)
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,128 @@
local U = require("tests.drivers.util")
-- ../pokecrystal/home/text.asm:630
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
return function(game)
local fails = 0
local function say(line) print("[2132] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
if not (world and world.map) then
say("FAIL the gen 2 world did not boot")
love.event.quit(1)
return
end
-- ../pokecrystal/maps/NewBarkTown.asm:296
world:warpToMapId("NEW_BARK_TOWN", 8, 9, "up")
U.wait(30)
ok(world.map and world.map.id == "NEW_BARK_TOWN", "standing under the town sign")
U.tap(game, "a")
local box
for _ = 1, 600 do
local top = game.stack and game.stack:top()
if top and top.isTextBox and top:arrowVisible() then box = top; break end
U.wait(1)
end
ok(box ~= nil, "the sign's first page is up and waiting on the arrow")
if not box then
love.event.quit(1)
return
end
box.blink = 0
U.shot(game, SHOT_DIR .. "/2132_arrow.png")
local canvas = love.graphics.newCanvas(160, 144)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 0, 1, 1)
box.blink = 0
box:draw()
love.graphics.setCanvas()
love.graphics.pop()
local id = canvas:newImageData()
local function px(x, y)
local r, g, b = id:getPixel(x, y)
return r, g, b
end
local function near(x, y, r, g, b)
local pr, pg, pb = px(x, y)
return math.abs(pr - r) < 0.05 and math.abs(pg - g) < 0.05
and math.abs(pb - b) < 0.05
end
local pr, pg, pb = px(12, 108)
say(("paper sampled at (12,108): %.2f %.2f %.2f"):format(pr, pg, pb))
ok(not near(12, 108, 1, 0, 1), "the box interior was painted")
local function paperInRow(y)
local n = 0
for x = 144, 151 do
if near(x, y, pr, pg, pb) then n = n + 1 end
end
return n
end
local function inkInCell()
local n = 0
for y = 136, 143 do
for x = 144, 151 do
local r, g, b = px(x, y)
if r < 0.3 and g < 0.3 and b < 0.3 then n = n + 1 end
end
end
return n
end
local ink = inkInCell()
say(("ink pixels in tile (18,17): %d"):format(ink))
ok(ink > 0, "the arrow is drawn in the corner cell")
local function inkInRow(cx, y)
local n = 0
for x = cx * 8, cx * 8 + 7 do
local r, g, b = px(x, y)
if r < 0.3 and g < 0.3 and b < 0.3 then n = n + 1 end
end
return n
end
local lineRows = {}
for y = 136, 143 do
if inkInRow(17, y) >= 6 then lineRows[#lineRows + 1] = y end
end
ok(#lineRows > 0, "tile (17,17) carries the frame line rows")
for _, y in ipairs(lineRows) do
ok(paperInRow(y) > 0, ("frame row %d of tile (18,17) shows paper under the arrow"):format(y - 136))
end
box.blink = 16
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 0, 1, 1)
box:draw()
love.graphics.setCanvas()
love.graphics.pop()
id = canvas:newImageData()
local same = true
for y = 136, 143 do
for x = 0, 7 do
local r1, g1, b1 = px(136 + x, y)
local r2, g2, b2 = px(144 + x, y)
if math.abs(r1 - r2) > 0.05 or math.abs(g1 - g2) > 0.05 or math.abs(b1 - b2) > 0.05 then
same = false
end
end
end
ok(same, "on the off phase tile (18,17) matches the plain frame tile (17,17)")
say("eyeball 2132_arrow.png: the down arrow in the bottom-right corner sits "
.. "in a paper cell with no frame lines through it (compare shots/2132_2.png)")
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,120 @@
-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno
-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText
local U = require("tests.drivers.util")
local SaveMenu = require("src.ui.gen2.SaveMenu")
local PcMenu = require("src.ui.gen2.PcMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-save-overwrite-2131"
local failed = 0
local function pass(ok, line)
if not ok then failed = failed + 1 end
U.log((ok and "PASS " or "FAIL ") .. line)
end
local function settle(menu)
for _ = 1, 600 do
if menu.typer and menu.typedPhase == menu.phase and menu.typer:done() then break end
U.wait(1)
end
U.wait(2)
end
U.wait(45)
assert(game.world and game.world.map, "gen2 world did not boot")
local closed = false
local menu = SaveMenu.new(game, {
existed = true,
writer = function() return true end,
onDone = function()
closed = true
game.stack:pop()
end,
})
game.stack:push(menu)
settle(menu)
pass(menu:yesNoVisible(), "confirm YES/NO is up after the question")
U.shot(game, out .. "/01-confirm.png")
U.tap(game, "a")
U.wait(2)
pass(menu.phase == "overwrite", "YES on an existing file opens the overwrite prompt")
settle(menu)
pass(menu.page == 1 and #menu.pages == 2,
("overwrite prompt is on page 1 of 2 (page %s of %s)"):format(
tostring(menu.page), tostring(menu.pages and #menu.pages)))
pass(not menu:yesNoVisible(), "no YES/NO while the cont page waits")
U.log("02-overwrite-page1: 'There is already a / save file. Is it' with the")
U.log("blinking arrow at the bottom right and NO yes/no box yet.")
U.shot(game, out .. "/02-overwrite-page1.png")
U.tap(game, "a")
U.wait(2)
pass(menu.page == 2, "A turns to the cont page")
pass(menu.phase == "overwrite", "without answering the question")
settle(menu)
pass(menu:yesNoVisible(), "YES/NO goes up once 'OK to overwrite?' has printed")
U.log("03-overwrite-page2: 'save file. Is it / OK to overwrite?' with the")
U.log("YES/NO box at (0,7) over the summary panel, as on the cart.")
U.shot(game, out .. "/03-overwrite-page2.png")
U.tap(game, "b")
U.wait(5)
pass(closed, "B on the overwrite YES/NO closes without saving")
local Mon = require("src.battle.gen2.Mon")
if not (game.save.party and #game.save.party > 0) then
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
end
local pc = PcMenu.new(game, {
save = game.save,
saveExists = true,
bills = true,
writer = function() return true end,
onClose = function() game.stack:pop() end,
})
game.stack:push(pc)
U.wait(3)
pc.picking = true
pc.pickIndex = ((game.save.currentBox or 1) % 14) + 1
pc:beginChangeBox(pc.pickIndex)
U.wait(3)
pass(pc.savePhase == "confirm" and pc:saveYesNoVisible(),
"CHANGE BOX asks '#MON BOX, data will be saved. OK?' with YES/NO up")
U.shot(game, out .. "/04-changebox-confirm.png")
U.tap(game, "a")
U.wait(3)
pass(pc.savePhase == "overwrite" and pc.savePage == 1,
"YES on an existing file opens the overwrite prompt on page 1")
pass(not pc:saveYesNoVisible(), "no YES/NO while the cont page waits")
U.log("05-changebox-page1: 'There is already a / save file. Is it', arrow,")
U.log("no yes/no box.")
U.shot(game, out .. "/05-changebox-page1.png")
U.tap(game, "a")
U.wait(3)
pass(pc.savePhase == "overwrite" and pc.savePage == 2,
"A turns to the cont page without answering")
pass(pc:saveYesNoVisible(), "YES/NO is up on 'OK to overwrite?'")
U.log("06-changebox-page2: 'save file. Is it / OK to overwrite?' with the")
U.log("YES/NO box at (14,7).")
U.shot(game, out .. "/06-changebox-page2.png")
U.tap(game, "b")
U.wait(3)
pass(pc.savePhase == nil and pc.picking,
"B on the overwrite YES/NO drops the change and keeps the box picker")
U.tap(game, "b")
U.wait(3)
U.tap(game, "b")
U.wait(5)
U.log(("%d check(s) failed"):format(failed))
U.log((failed == 0 and "PASS" or "FAIL") .. " gen2 save overwrite cont page (#2131) shots in " .. out)
while true do coroutine.yield() end
end
@@ -92,6 +92,60 @@ do
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
end
-- ../pokecrystal/engine/menus/save.asm:39 ChangeBoxSaveGame
local ARROW_X, ARROW_Y = 18 * 8, 17 * 8
do
local input = { pressed = {} }
function input:press(button) self.pressed[button] = true end
function input:wasPressed(button)
if self.pressed[button] then
self.pressed[button] = nil
return true
end
return false
end
local menu = PcMenu.new({ input = input }, {
save = SAVE,
saveExists = true,
writer = function() return true end,
})
menu.picking = true
menu.pickIndex = 2
menu:beginChangeBox(2)
T.eq(menu.savePhase, "confirm", "CHANGE BOX asks to save first")
drawn = {}
menu:drawPanel()
T.eq(drawnAt(YES_X, YES_Y), "YES", "with its YES/NO up at once")
input:press("a")
menu:update(0)
T.eq(menu.savePhase, "overwrite", "YES on an existing file asks to overwrite")
T.eq(menu.savePage, 1, "on the prompt's first page")
menu.arrowBlink = 0
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "page one, first line")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "page one, second line")
T.eq(drawnAt(YES_X, YES_Y), nil, "no YES on page one")
T.eq(drawnAt(ARROW_X, ARROW_Y), "\xe2\x96\xbc", "the cont arrow blinks at (18,17)")
input:press("a")
menu:update(0)
T.eq(menu.savePhase, "overwrite", "A turns the page rather than answering")
T.eq(menu.savePage, 2, "to the cont page")
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "save file. Is it", "page two scrolls the second line up")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OK to overwrite?", "and prints the cont line")
T.eq(drawnAt(YES_X, YES_Y), "YES", "YES is up on the last page")
T.eq(drawnAt(ARROW_X, ARROW_Y), nil, "and the arrow is gone")
input:press("b")
menu:update(0)
T.eq(menu.savePhase, nil, "B on the YES/NO is NO and drops the change")
T.eq(menu.picking, true, "with the box picker still up")
end
-- ------------------------------------------------- a translation mod's turn
--
-- Same catalog values as gen2_save_menu_translation_test.lua's own
@@ -105,7 +159,7 @@ do
["YES"] = "OUI",
["NO"] = "NON",
["#MON BOX, data\nwill be saved. OK?"] = "Les donnees de la\nBOITE seront sauv.",
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
["There is already a\nsave file. Is it\vOK to overwrite?"] = "Un fichier existe\ndeja. Est-ce\vOK pour ecraser?",
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
["Could not save."] = "Echec de sauvegarde.",
+100 -31
View File
@@ -103,7 +103,7 @@ do
["YES"] = "OUI",
["NO"] = "NON",
["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?",
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
["There is already a\nsave file. Is it\vOK to overwrite?"] = "Un fichier existe\ndeja. Est-ce\vOK pour ecraser?",
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
["Could not save."] = "Echec de sauvegarde.",
@@ -156,32 +156,101 @@ do
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
-- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving
-- src/ui/gen2/PcMenu.lua:savePages() shares SaveMenu's overwrite/saving
-- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and
-- SaveMenu.twoLines(), rather than duplicating them -- both exported below,
-- SaveMenu.pagesOf(), rather than duplicating them -- both exported below,
-- both used by PcMenu's own translation test
-- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked
-- here that they stay callable the shape twoLines() expects: a table in,
-- one \n-joined string out with the split back on load.
-- here that they stay callable the shape pagesOf() expects: one
-- \n/\v-joined string in, a list of two-line pages out.
-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText
do
T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it",
"OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a",
"and twoLines() splits its untranslated fallback back to the first line")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it",
"and its second line")
T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE,
"There is already a\nsave file. Is it\vOK to overwrite?",
"OVERWRITE_PROMPT_SOURCE is the cart's whole text, cont line included")
local pages = SaveMenu.pagesOf(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))
T.eq(#pages, 2, "and pagesOf() turns the cont into a second page")
T.eq(pages[1][1], "There is already a", "page one, first line")
T.eq(pages[1][2], "save file. Is it", "page one, second line")
T.eq(pages[2][1], "save file. Is it", "page two scrolls the old second line up")
T.eq(pages[2][2], "OK to overwrite?", "and prints the cont line under it")
T.eq(SaveMenu.SAVING_PROMPT_SOURCE, "SAVING… DON'T TURN\nOFF THE POWER.",
"SAVING_PROMPT_SOURCE stays the cart's own \\n-joined text")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[1], "SAVING… DON'T TURN",
"and twoLines() splits its untranslated fallback back to the first line")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[2], "OFF THE POWER.",
"and its second line")
pages = SaveMenu.pagesOf(Strings(SaveMenu.SAVING_PROMPT_SOURCE))
T.eq(#pages, 1, "a \\n-only message is a single page")
T.eq(pages[1][1], "SAVING… DON'T TURN", "with its first line")
T.eq(pages[1][2], "OFF THE POWER.", "and its second line")
T.eq(SaveMenu.pagesOf("Could not save.")[1][2], nil,
"a one-line message leaves the second slot empty")
end
-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno
local ARROW_X, ARROW_Y = 18 * 8, 17 * 8
local function newInput()
local input = { pressed = {} }
function input:press(button) self.pressed[button] = true end
function input:wasPressed(button)
if self.pressed[button] then
self.pressed[button] = nil
return true
end
return false
end
function input:isDown() return false end
return input
end
do
local input = newInput()
local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = true })
typeOut(menu)
input:press("a")
menu:update(0)
T.eq(menu.phase, "overwrite", "YES on an existing file asks to overwrite")
typeOut(menu)
menu.arrowBlink = 0
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "page one, first line")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "page one, second line")
T.eq(drawnAt(YES_X, YES_Y), nil, "no YES on page one")
T.eq(drawnAt(ARROW_X, ARROW_Y), "\xe2\x96\xbc", "the cont arrow blinks at (18,17)")
input:press("a")
menu:update(0)
T.eq(menu.page, 2, "A turns to the cont page")
typeOut(menu)
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "save file. Is it", "page two scrolls the second line up")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OK to overwrite?", "and prints the cont line")
T.eq(drawnAt(YES_X, YES_Y), "YES", "YES is up on the last page")
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
T.eq(drawnAt(ARROW_X, ARROW_Y), nil, "and the arrow is gone")
end
do
Strings.load({
strings = {
["There is already a\nsave file. Is it\vOK to overwrite?"] = "Ecraser la\nsauvegarde?",
},
})
local input = newInput()
local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = true })
typeOut(menu)
input:press("a")
menu:update(0)
typeOut(menu)
T.eq(#menu.pages, 1, "a translation without \\v is one page")
T.check(menu:yesNoVisible(), "and its YES/NO goes straight up")
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ecraser la", "the translated first line")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarde?", "and second")
T.eq(drawnAt(YES_X, YES_Y), "YES", "with YES drawn")
Strings.load({})
end
-- A translation with a THIRD line (a second embedded "\n") has nowhere on
-- screen to go -- drawPanel's box has room for exactly two Chrome.print
-- calls -- so it must not silently draw the literal newline byte as glyph
-- garbage on the second line, and should warn so a translator notices.
do
Strings.load({
strings = {
@@ -189,24 +258,24 @@ do
},
})
local warned = {}
require("src.core.Logger").warn = function(fmt, ...)
warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
local input = newInput()
local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = false })
typeOut(menu)
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois",
"the rest lands in the second slot rather than vanishing")
T.check(#warned == 1, "and a single warning is logged")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "the first line reaches the box")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux", "and the second, without the third")
T.eq(drawnAt(YES_X, YES_Y), nil, "no YES while a page is still to come")
input:press("a")
menu:update(0)
typeOut(menu)
drawn = {}
menu:drawPanel()
T.check(#warned == 1, "the warning does not repeat for the same text")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne trois", "the third line gets its own page")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y) or "", "", "with an empty second slot")
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES on the last page")
require("src.core.Logger").warn = function() end
Strings.load({})
end
@@ -0,0 +1,95 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
require("src.core.Logger").warn = function() end
local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local TextBox = require("src.render.TextBox")
local ARROW = Theme.moreArrow or 0xEE
local CREAM = { 231, 227, 231 }
local function newGame(gen, paper)
return {
save = { player = {}, options = { textSpeed = "FAST" }, generation = gen },
data = { text = {} },
input = {
wasPressed = function() return false end,
isDown = function() return false end,
},
textboxPaper = paper and function() return paper end or nil,
}
end
local events
local realDraw, realFill = Font.drawCode, Chrome.paletteFill
Font.drawCode = function(code, x, y)
events[#events + 1] = { kind = "glyph", code = code, x = x, y = y }
end
Chrome.paletteFill = function(x, y, w, h, palette)
events[#events + 1] = { kind = "fill", x = x, y = y, w = w, h = h,
palette = palette }
end
local function frame(box, blink)
box.blink = blink
events = {}
box:draw()
local fill, arrow
for i, e in ipairs(events) do
if e.kind == "fill" and e.x == 144 and e.y == 136 then fill = i end
if e.kind == "glyph" and e.code == ARROW then arrow = i end
end
return fill, arrow
end
local function box(gen, paper)
local b = TextBox.new(newGame(gen, paper), "A")
b.waiting = true
b.shown = {}
return b
end
-- ../pokecrystal/home/text.asm:630
do
local gold = box(2)
local fill, arrow = frame(gold, 0)
check(arrow, "gold arrow draws on the on phase")
check(fill, "and the (18,17) border cell is painted paper first")
check(fill and arrow and fill < arrow, "paper goes under the arrow, not over it")
local e = fill and events[fill]
check(e and e.w == 8 and e.h == 8, "one whole 8x8 tile")
check(e and e.palette == Chrome.DEFAULT_BOX_PALETTE,
"through the box's own palette")
end
-- ../pokecrystal/home/joypad.asm:458
do
local gold = box(2)
local fill, arrow = frame(gold, 16)
eq(arrow, nil, "gold arrow is off from frame 16")
eq(fill, nil, "and the border tile is left alone on the off phase")
end
do
local gear = box(2, CREAM)
local fill = frame(gear, 0)
local e = fill and events[fill]
check(e and e.palette and e.palette[1] == CREAM,
"a pushed textbox on the Pokegear's cream paper fills the cell cream")
end
do
local red = box(1)
local fill, arrow = frame(red, 0)
check(arrow, "gen1 arrow still draws")
eq(fill, nil, "and gen1 never paints the cell (its arrow sits on the interior)")
end
Font.drawCode, Chrome.paletteFill = realDraw, realFill
T.finish()
@@ -64,6 +64,24 @@ while Typer.typing(menu) and frames < 600 do
frames = frames + 1
end
check(frames > 0 and frames < 600, "the overwrite question finishes typing")
-- ../pokecrystal/data/text/common_3.asm:205
eq(menu.page, 1, "the overwrite prompt's first page is up")
eq(#menu.pages, 2, "and it has a cont page after it")
check(not menu:yesNoVisible(), "no YES/NO while the cont page waits for a button")
input:press("a")
menu:update(0)
eq(menu.page, 2, "A turns to the cont page")
eq(menu.phase, "overwrite", "without answering the question")
check(Typer.typing(menu), "the cont page types letter by letter")
check(not menu:yesNoVisible(), "and the YES/NO waits for it")
frames = 0
while Typer.typing(menu) and frames < 600 do
menu:update(0)
frames = frames + 1
end
check(frames > 0 and frames < 600, "the cont page finishes typing")
check(menu:yesNoVisible(), "then the overwrite YES/NO goes up")
input:press("a")
+2 -1
View File
@@ -850,7 +850,8 @@ do
if event.kind == "send" then sentOut = event.text end
end
check("EnemyWithdrewText comes first", withdrew, "JOEY withdrew GEODUDE!")
check("then the send-out line", sentOut, "JOEY sent out PIDGEY!")
-- ../pokecrystal/data/text/battle.asm:240-246
check("then the send-out line", sentOut, "JOEY\nsent out\vPIDGEY!")
-- A refused RUN costs nothing. .cant_run_from_trainer leaves
-- wBattlePlayerAction at BATTLEPLAYERACTION_USEMOVE, so BattleMenu_Run's
+67 -1
View File
@@ -1754,7 +1754,7 @@ end
do
local screen, lead = learnScreen()
screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1,
text = "JOE sent out PIDGEY!" })
text = Battle.sentOutText("JOE", "PIDGEY") })
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
local tap = tapper(screen)
tap("a") -- read the question
@@ -1779,6 +1779,72 @@ do
"and its line prints ahead of the send-out that was already queued")
end
-- ../pokecrystal/data/text/battle.asm:240-246
-- ../pokecrystal/engine/battle/core.asm:3146-3147
-- ../pokecrystal/home/text.asm:502-526
do
local function typed(screen)
for _ = 1, 400 do
if not screen:syncTyper() then break end
Input:step()
screen:update(1 / 60)
end
return table.concat(screen:messageLines(), "|")
end
local function sendOutScreen(trainer, monName)
local screen, battle = newScreen()
check(runToMenu(screen), "reached the menu")
battle:takeEvents()
local mon = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
mon.nickname = monName
screen.phase = "resolving"
screen.showEnemyHud = false
screen:push({ kind = "send", side = "enemy", mon = mon, hp = mon.hp,
text = Battle.sentOutText(trainer, monName) })
screen:advanceQueue()
return screen
end
local function sendStarted(screen)
return screen.afterSendOut ~= nil or screen.showEnemyHud == true
end
local screen = sendOutScreen("CHAMPION LANCE", "DRAGONITE")
eq(typed(screen), "CHAMPION LANCE|sent out",
"the trainer and 'sent out' are the first page, no name on it")
check(not sendStarted(screen) and screen.pendingSendOut ~= nil,
"the ball stays shut while the first page waits for A")
local tap = tapper(screen)
tap("a")
Input:step()
screen:update(1 / 60)
local lines = screen:messageLines()
eq(lines[1], "sent out", "cont scrolls 'sent out' up already printed")
check(lines[2] ~= "DRAGONITE!", "and only the name types")
eq(typed(screen), "sent out|DRAGONITE!",
"a nine-letter name lands whole on the scrolled row")
check(sendStarted(screen) and screen.pendingSendOut == nil,
"and ANIM_SEND_OUT_MON starts with the name page")
local short = sendOutScreen("CHAMPION LANCE", "PIDGEY")
eq(typed(short), "CHAMPION LANCE|sent out",
"a short name still gets the cart's unconditional third row")
tapper(short)("a")
eq(typed(short), "sent out|PIDGEY!", "and lands on the scrolled page")
local long = newScreen()
check(runToMenu(long), "reached the menu")
long.battle:takeEvents()
long.phase = "resolving"
long:push({ kind = "message",
text = "SOMELONGNAME gained 12345 EXP. Points!" })
long:advanceQueue()
eq(typed(long), "SOMELONGNAME|gained 12345 EXP.",
"an overlong line shows its first two rows")
tapper(long)("a")
eq(typed(long), "gained 12345 EXP.|Points!",
"and the third row scrolls in instead of being cut")
end
-- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------
-- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478
do
+7 -2
View File
@@ -138,10 +138,15 @@ do
end,
})
vm:start("s:t")
eq(table.concat(order, ","),
"start:CATCH_TUTORIAL,battle,stop,reload",
"the battle runs inside the lockout, and the script parks on the "
.. "map reload")
check(vm:running(), "catchtutorial is not a terminator")
vm:update()
eq(table.concat(order, ","),
"start:CATCH_TUTORIAL,battle,stop,reload,text",
"the battle runs inside the lockout, and the script continues after the "
.. "map reload -- catchtutorial is not a terminator")
"and the script continues once the map setup has run")
eq(got and got.battleType, 3, "wBattleType is the command's own byte")
eq(got and got.wild and got.wild.species, 19,
"and the wild mon is the one loadwildmon left behind")
+45
View File
@@ -255,6 +255,51 @@ do
end
end
-- ../pokecrystal/home/text.asm:473
do
local Chrome = require("src.ui.gen2.Chrome")
local priorPrint = Chrome.print
local rows = {}
Chrome.print = function(text, _tx, ty) rows[text] = ty end
local screen = InitClock.new({}, { save = {} })
screen:drawPanel()
Chrome.print = priorPrint
eq(rows["Zzz... Hm? Wha...?"], 14, "Oak's first line prints on row 14")
eq(rows["You woke me up!"], 16, "and `line` puts the second on row 16")
end
-- ../pokecrystal/engine/rtc/timeset.asm:385
do
local Chrome = require("src.ui.gen2.Chrome")
local day = InitClock.new({}, { mode = "day", save = {} })
eq(day.isOpaque, false, "SetDayOfWeek draws over the map")
eq(day:drawsWidescreen(), false, "and paints no surround of its own")
eq(day:wantsFillScale(), false, "nor asks for the fill scale")
local clock = InitClock.new({}, { mode = "clock", save = {} })
eq(clock.isOpaque, true, "InitClock still runs on a cleared page")
eq(clock:drawsWidescreen(), true, "with its own widescreen surround")
local priorClear, cleared = Chrome.clear, 0
Chrome.clear = function() cleared = cleared + 1 end
day:drawPanel()
eq(cleared, 0, "the day wheel never clears the tilemap")
clock:drawPanel()
eq(cleared, 1, "the clock does")
Chrome.clear = priorClear
end
-- ../pokecrystal/home/text.asm:502
do
local screen = InitClock.new({}, { save = {}, hour = 12, minute = 0 })
screen.phase = "response"
eq(InitClock.responseKey(12), "yikes", "noon is the three-line response")
local pages = screen:pages()
eq(#pages, 2, "`cont` scrolls the third line in as a second page")
check(pages[1]:find("Yikes! I over-", 1, true) ~= nil
and not pages[1]:find("slept!", 1, true), "page one holds lines 1-2")
check(pages[2]:find("^Yikes! I over%-\nslept!$") ~= nil,
"page two keeps line 2 on top and scrolls line 3 under it")
end
-- Clock.DAY_NAMES / Clock.weekdayName / Clock.daytimeLabel: the single home
-- InitClock, MainMenu and the Pokegear clock card all share, so a weekday
-- cannot be named one way on one screen and another way on the next.
+10 -5
View File
@@ -193,10 +193,12 @@ end
-- home/text.asm:566
do
local Chrome = require("src.ui.gen2.Chrome")
local priorPrint, priorWrapped = Chrome.print, Chrome.printWrapped
local printed = {}
Chrome.print = function(text) printed[#printed + 1] = text end
Chrome.printWrapped = function(text) printed[#printed + 1] = text end
local priorPrint = Chrome.print
local printed, rows = {}, {}
Chrome.print = function(text, _tx, ty)
printed[#printed + 1] = text
rows[text] = ty
end
local done = GenderSelect.new({ data = { text = {
_AreYouABoyOrAreYouAGirlText = "Are you a boy?\nOr are you a girl?{DONE}",
} } })
@@ -205,11 +207,14 @@ do
done:drawPanel()
check(not table.concat(printed, "|"):find("DONE", 1, true),
"and never reaches the tile grid")
-- ../pokecrystal/home/text.asm:473
eq(rows["Are you a boy?"], 14, "the prompt's first line sits on row 14")
eq(rows["Or are you a girl?"], 16, "and `line` drops the second to row 16")
local prompt = GenderSelect.new({ data = { text = {
_AreYouABoyOrAreYouAGirlText = "Are you a boy?{PROMPT}",
} } })
eq(prompt.text, "Are you a boy?", "nor is {PROMPT}")
Chrome.print, Chrome.printWrapped = priorPrint, priorWrapped
Chrome.print = priorPrint
end
-- ------------------------------------------------- the beat in Oak's speech
+59
View File
@@ -240,6 +240,65 @@ previous, alpha = BorderFill.crossfade(bare, trees, nil)
check("a caller with no map key never fades", previous, nil)
check("and draws opaque", alpha, 1)
-- JumpStep engine/overworld/movement.asm:747-776
-- map_objects.asm:1828-1849
check("the curve starts four pixels up", Movement.jumpYOffset(1, 16), -4)
check("peaks at twelve", Movement.jumpYOffset(6, 16), -12)
check("and lands on the tile", Movement.jumpYOffset(16, 16), 0)
check("a doubled step reads the same peak", Movement.jumpYOffset(12, 32), -12)
local Player = require("src.world.gen2.Player")
local hopper = Player.new(3, 4, "down")
hopper.fixedFacing = true
hopper:scriptFace("up")
check("a scripted jump starts", hopper:scriptJump("down"), true)
check("it targets two cells", hopper.targetY, 6)
check("keeps a fixed facing", hopper.facing, "up")
check("and is a jump", hopper.jumping, true)
check("over one doubled step", hopper.stepFrames, 32)
local lowest, shadowFrames = 0, 0
for _ = 1, 32 do
hopper:update()
lowest = math.min(lowest, hopper.spriteYOffset or 0)
if hopper.jumping then shadowFrames = shadowFrames + 1 end
end
check("the player arc peaks at -12", lowest, -12)
check("the shadow flag holds for the whole hop", shadowFrames, 31)
check("it lands two cells down", hopper.cellY, 6)
check("with the offset cleared", hopper.spriteYOffset, 0)
check("and the jump over", hopper.jumping, nil)
check("a fixed-facing scripted step also keeps its facing",
hopper:scriptStep("left") and hopper.facing, "up")
check("while still moving left", hopper.targetX, 2)
hopper.fixedFacing = nil
local jumper = NPC.new("NEW_BARK_TOWN", { index = 2, x = 4, y = 5,
movement = 1 }, SPRITE)
jumper.fixedFacing = nil
check("an NPC jump starts", jumper:scriptJump("right"), true)
check("it targets two cells", jumper.targetX, 6)
check("faces the way it jumps", jumper.facing, "right")
check("and is a jump", jumper.jumping, true)
local npcLowest, npcFrames = 0, 0
while jumper.moving and npcFrames < 64 do
jumper:update(nil, {})
npcFrames = npcFrames + 1
npcLowest = math.min(npcLowest, jumper.spriteYOffset or 0)
end
check("the NPC arc peaks at -12", npcLowest, -12)
check("over the doubled step", npcFrames, 32)
check("it lands two cells right", jumper.cellX, 6)
check("on the tile", jumper.spriteYOffset, 0)
check("with the jump over", jumper.jumping, nil)
check("a following plain step is back to one cell",
jumper:scriptStep("down") and jumper.targetY, 6)
local plainFrames = 0
while jumper.moving and plainFrames < 64 do
jumper:update(nil, {})
plainFrames = plainFrames + 1
end
check("at the walking pace", plainFrames, 16)
print(("gen2 field anim: %d checks, %d failures"):format(checks, failures))
if failures > 0 then
error(("%d assertion(s) failed"):format(failures), 0)
+4 -3
View File
@@ -172,10 +172,11 @@ do
for _ = 1, 30 do world:updatePeople() end
eq(npc.facing, before, "his spin cannot roll a new facing mid-script")
-- Drain the engagement: the emote's 30-frame hold, the seen text, the
-- battle, the after script. updatePeople keeps running under it, which is
-- exactly when an unfrozen spinner would drift.
-- battle, the reload's FadeInFromWhite, the after script. updatePeople
-- keeps running under it, which is exactly when an unfrozen spinner would
-- drift.
local drifted = false
for _ = 1, 60 do
for _ = 1, 90 do
world:step()
world:updatePeople()
if world.vm:running() and npc.facing ~= before then drifted = true end
+6 -1
View File
@@ -221,8 +221,11 @@ local function runScript(opts)
reloadMap = function() log.reloaded = (log.reloaded or 0) + 1 end,
})
local started = vm:start(map.coordEvents[1].scriptKey)
local parkedAfterReload = log.reloaded and vm.busy and #moves == 1
for _ = 1, 4 do vm:update() end
return {
started = started, busy = vm.busy, log = log, moves = moves,
parkedAfterReload = parkedAfterReload,
battles = battles, save = save,
}
end
@@ -237,7 +240,9 @@ end
do -- the first visit of the day, accepted
local run = runScript({ accept = true })
check(run.started, "the script starts")
check(not run.busy, "and runs to completion without parking on a command")
check(run.parkedAfterReload,
"reloadmapafterbattle parks the script for the map setup")
check(not run.busy, "then it runs to completion")
eq(run.log.turned and run.log.turned.facing, "up",
"turnobject PLAYER, UP squares the player up to the desk")
check(saw(run.log, "TRAINING HALL"), "the welcome")
+37
View File
@@ -161,6 +161,9 @@ local vm = Vm.new(scripts, texts, events, {
vm.trainerObject = record
check(vm:start(SEEN_SCRIPT), "an inline command list runs")
for _ = 1, 60 do vm:update() end
check(battled and vm:running() and #shown == 1,
"reloadmapafterbattle parks the script for the map setup")
vm:update()
check(not vm:running(), "the trainer script finished")
eq(emoted, -2, "the bubble goes over LAST_TALKED")
check(approached, "the trainer walked up")
@@ -170,6 +173,40 @@ eq(shown[1], "Wait! Let's battle!", "TRAINERTEXT_SEEN is the struct's seen text"
eq(shown[2], "Train harder.", "the after-battle script ran")
check(events:get(BEAT_FLAG), "SET_FLAG marked the trainer beaten")
-- engine/overworld/scripting.asm:1209
do
local reloaded, opened = {}, {}
local rvm = Vm.new(scripts, texts, events, {
showText = function(body, onDone) opened[#opened + 1] = body onDone() end,
reloadMap = function(setup) reloaded[#reloaded + 1] = setup end,
startBattle = function(_trainer, _wild, onDone) onDone("win") end,
})
check(rvm:start({
{ op = "startbattle" },
{ op = "reloadmapafterbattle" },
{ op = "opentext" },
{ op = "writetext", text = "t:after" },
{ op = "closetext" },
{ op = "end" },
}), "battle-then-text script runs")
eq(#reloaded, 1, "reloadmapafterbattle ran the reload hook")
eq(reloaded[1], true, "as a map setup")
eq(#opened, 0, "and parked the script before the text")
check(rvm:running(), "the script is still live")
rvm:update()
eq(#opened, 1, "the text opens on the next VM tick")
check(not rvm:running(), "and the script runs out")
reloaded, opened = {}, {}
rvm:start({
{ op = "refreshmap" },
{ op = "writetext", text = "t:after" },
{ op = "end" },
})
eq(reloaded[1], false, "refreshmap runs no setup script")
eq(#opened, 1, "and does not park")
end
-- A beaten trainer takes the CHECK_FLAG branch straight to its after script.
local TALK_SCRIPT = {
{ op = "faceplayer" },
+41
View File
@@ -2019,6 +2019,44 @@ objWorld:appearObject(3)
check(objWorld.objectMasks["TEST_MAP:2"] == false,
"and appear unmasks it, so the pair is not one-way")
-- maps/LancesRoom.asm:118
do
local rebuildsBefore = objWorld.rebuilds or 0
objWorld:disappearObject(0)
check(objWorld.playerMasked == true,
"disappear PLAYER masks the player sprite")
check(objWorld.objectMasks["TEST_MAP:-1"] == nil, "and touches no map object")
eq(objWorld.rebuilds or 0, rebuildsBefore, "and rebuilds nobody")
objWorld:appearObject(0)
check(not objWorld.playerMasked, "appear PLAYER unmasks it")
end
-- NewBarkTown_RivalShovesYouOutMovement maps/NewBarkTown.asm:178-183
do
local Player = require("src.world.gen2.Player")
local jw = hookWorld()
jw.player = Player.new(3, 4, "down")
local done = false
jw:beginMovement(0, { 0x01, 0x3b, 0x30, 0x3a, 0x47 }, function() done = true end)
jw:updateMovement()
eq(jw.player.facing, "up", "turn_head UP turns the player")
jw:updateMovement()
check(jw.player.moving and jw.player.jumping, "jump_step DOWN is a jump")
eq(jw.player.targetY, 6, "two cells long")
eq(jw.player.facing, "up", "under fix_facing the player keeps facing up")
eq(jw.moveState and jw.moveState.pendingStep, nil, "with no second walk queued")
local lowest = 0
for _ = 1, 32 do
jw.player:update()
lowest = math.min(lowest, jw.player.spriteYOffset or 0)
jw:updateMovement()
end
eq(lowest, -12, "the sprite arcs twelve pixels up on the way")
eq(jw.player.cellY, 6, "and lands two cells down")
check(done, "then the stream ends")
check(not jw.player.fixedFacing, "remove_fixed_facing released the facing")
end
-- Three object_events SHARING one MAPOBJECT_EVENT_FLAG is ordinary: the
-- animated Burned Tower beasts all carry EVENT_BURNED_TOWER_B1F_BEASTS_1
-- (maps/BurnedTowerB1F.asm:152) and ReleaseTheBeasts appears and jumps them
@@ -2351,8 +2389,11 @@ setupWorld.setMap = function() setupLoads = setupLoads + 1 return true end
setupWorld:runMapSetup(0xf7, function() return setupWorld:setMap() end)
eq(setupLoads, 1, "MAPSETUP_CONNECTION loads on the spot")
check(setupWorld.mapSetup == nil, "with no chain behind it")
setupWorld.playerMasked = true
setupWorld:runMapSetup(0xf1, function() return setupWorld:setMap() end)
eq(setupLoads, 2, "MAPSETUP_WARP opens on DisableLCD, so it loads at once too")
check(setupWorld.playerMasked == nil,
"and the load respawns a `disappear`ed player")
eq(setupWorld.mapSetup.phase, "in", "and only fades back in")
setupWorld.mapSetup = nil
setupWorld:runMapSetup(0xf6, function() return setupWorld:setMap() end)