-- The Ruins of Alph sliding-panel puzzle (engine/games/unown_puzzle.asm
-- _UnownPuzzle), opened by `special UnownPuzzle` from each chamber's `bg_event
-- BGEVENT_UP` wall.
--
-- Sixteen panels of a picture start on the OUTER ring of a 6x6 board and the
-- interior 4x4 is empty; A picks a panel up, A puts it down on an empty cell,
-- and the puzzle is solved when the interior holds panels 1..16 in reading
-- order.  START quits at any time.  The `setval UNOWNPUZZLE_*` in front of the
-- special picks which of the four pictures is being assembled (Kabuto,
-- Omanyte, Aerodactyl, Ho-Oh), and `wSolvedUnownPuzzle` comes back out in
-- wScriptVar so the chamber's `iftrue` can drop the floor.
--
-- The board is a TILEMAP, so every coordinate below is the hlcoord the ASM
-- writes and not a number picked to look right:
--
--   hlcoord 0, 0 + SCREEN_AREA  filled with PUZZLE_BORDER ($ee)
--   hlcoord 4, 3, lb bc 12, 12  filled with PUZZLE_VOID ($ef) -- the interior
--   UnownPuzzleCoordData        cell i sits at (1 + 3*(i%6), 3*(i/6)), so the
--                               6x6 board of 3x3-tile cells spans (1,0)..(18,17)
--   PlaceStartCancelBoxBorder   the box at rows 15-17, columns 4-15
--   PlaceStartCancelBox         $f6..$ff, the ten caption tiles, at (5,16)
--
-- The 16 initial positions are exactly the ring cells (.PuzzlePieceInitialPositions
-- lists all six of row 0, both ends of rows 1-4, and both ends of row 5), which
-- is why a fresh board is full on the outside and empty in the middle.
--
-- The cursor is an OBJ, four tiles mirrored into a 3x3 bracket, and it BLINKS:
-- RedrawUnownPuzzlePieces only runs when `hVBlankCounter and $10` is set, which
-- is 16 frames on and 16 off -- except while a panel is held, when the panel
-- itself rides the cursor and is drawn every frame.
--
-- The art comes out of the cache as menu_gfx.unownPuzzle (the extractor already
-- does ConvertLoadedPuzzlePieces' 2x enlargement and the border pass, so a
-- picture is a plain 96x96 sheet of 4x4 panels).  Without it the board still
-- plays: panels draw as their numbers, which is enough to solve and enough for
-- a test, and is the same degrade src/ui/gen2/CardFlip.lua takes.

local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local TileSheet = require("src.ui.gen2.TileSheet")

local UnownPuzzle = {}
UnownPuzzle.__index = UnownPuzzle
UnownPuzzle.isOpaque = true

-- DEF PUZZLE_BORDER EQU $ee / DEF PUZZLE_VOID EQU $ef.
UnownPuzzle.BORDER_TILE = 0xee
UnownPuzzle.VOID_TILE = 0xef

-- PREDEFPAL_UNOWN_PUZZLE (gfx/sgb/predef.pal), transcribed at the extractor's
-- own 5-bit to 8-bit scale so a re-imported cache produces these same bytes:
-- RGB 31,31,31 / 24,20,11 / 18,13,11 / 00,00,00, i.e. white, tan, dark brown,
-- black.  Used when the cache predates the extractor emitting the palette; the
-- same inline transcription src/ui/gen2/GameFreakPresents.lua keeps.
UnownPuzzle.PALETTE = {
  { 255, 255, 255 }, { 197, 165, 90 }, { 148, 107, 90 }, { 0, 0, 0 },
}
-- _CGB_UnownPuzzle loads the same palette into wOBPals1 and then overwrites
-- OBJ colour 0 with `palred 31` (pure red); _UnownPuzzle's `ld a, $24 / call
-- DmgToCgbObjPal0` reorders that to entries 0, 1, 2, 0.  The cursor sheet uses
-- colours 0 and 3, so the bracket comes out red on both.
UnownPuzzle.CURSOR_PALETTE = {
  { 255, 0, 0 }, { 197, 165, 90 }, { 148, 107, 90 }, { 255, 0, 0 },
}

UnownPuzzle.COLUMNS = 6
UnownPuzzle.ROWS = 6
UnownPuzzle.CELLS = 36
UnownPuzzle.PIECES = 16
-- A cell is 3x3 tiles, and the sheet lays the sixteen panels 4 across.
UnownPuzzle.PIECE_TILES = 3
UnownPuzzle.PIECES_WIDE = 4

-- `DEF puzcoord EQUS "* 6 +"`: the cart addresses a cell as row * 6 + column,
-- 0-based, and every cursor rule below is a comparison against one of those.
local function puzcoord(row, col) return row * UnownPuzzle.COLUMNS + col end
UnownPuzzle.puzcoord = puzcoord

-- .PuzzlePieceInitialPositions: the sixteen cells a panel may start on.  All of
-- row 0, then both ends of rows 1 through 5.
UnownPuzzle.START_CELLS = (function()
  local out = {}
  for col = 0, 5 do out[#out + 1] = puzcoord(0, col) end
  for row = 1, 5 do
    out[#out + 1] = puzcoord(row, 0)
    out[#out + 1] = puzcoord(row, 5)
  end
  return out
end)()

-- .SolvedPuzzleConfiguration, written out the way the ASM writes it: the ring
-- empty, 1..16 filling the interior in reading order.
UnownPuzzle.SOLVED = {
  0, 0, 0, 0, 0, 0,
  0, 1, 2, 3, 4, 0,
  0, 5, 6, 7, 8, 0,
  0, 9, 10, 11, 12, 0,
  0, 13, 14, 15, 16, 0,
  0, 0, 0, 0, 0, 0,
}

-- SFX_*, in pokegold's own labels; Gold's sfx table is keyed by those.
local SFX_MOVE_CURSOR = "Sfx_Pound"
local SFX_MOVE_PIECE = "Sfx_MovePuzzlePiece"
local SFX_PICK_UP = "Sfx_MegaKick"
local SFX_PUT_DOWN = "Sfx_PlacePuzzlePieceDown"
local SFX_WRONG = "Sfx_Wrong"
local SFX_SOLVED = "Sfx_1stPlace"

-- JoyTextDelay with hInMenu set reads hJoyDown, so the d-pad REPEATS: a fresh
-- press reloads wTextDelayFrames with 15 and every repeat after that with 5.
local REPEAT_DELAY = 15
local REPEAT_RATE = 5

-- The ten caption tiles ($f6..$ff) spell START▶CANCEL; the same words are
-- printed with the font when the sheet is not in the cache.  Declared here and
-- looked up at the draw site, which is what Strings.source marks.
UnownPuzzle.CAPTION = Strings.source("START>CANCEL")

-- ---------------------------------------------------------------- the board
--
-- InitUnownPuzzlePiecePositions: sixteen panels are dealt, `call Random / and
-- $f` into the initial-position list, rerolling while the cell it picks is
-- already taken.  `random(n)` is 0..n-1, the src/battle/gen2 convention.
--
-- The cart's reroll is unbounded; it cannot hang because hRandomAdd is never
-- degenerate.  Here the retries are capped and the fallback drops the panel in
-- the first free start cell, so a fixed RNG in a test deals a legal board
-- instead of spinning.
local DEAL_RETRIES = 512

function UnownPuzzle.deal(random)
  local pieces = {}
  for cell = 1, UnownPuzzle.CELLS do pieces[cell] = 0 end
  for piece = 1, UnownPuzzle.PIECES do
    local cell, tries = nil, 0
    repeat
      cell = UnownPuzzle.START_CELLS[random(16) + 1]
      tries = tries + 1
    until pieces[cell + 1] == 0 or tries >= DEAL_RETRIES
    if pieces[cell + 1] ~= 0 then
      for _, candidate in ipairs(UnownPuzzle.START_CELLS) do
        if pieces[candidate + 1] == 0 then cell = candidate break end
      end
    end
    pieces[cell + 1] = piece
  end
  return pieces
end

-- CheckSolvedUnownPuzzle: a byte-for-byte compare against
-- .SolvedPuzzleConfiguration.  With exactly sixteen panels on the board this is
-- the same thing as "the interior is 1..16", but the compare is transcribed
-- rather than replaced so a board that ever held a different number of panels
-- would still answer correctly.
function UnownPuzzle.isSolved(pieces)
  for cell = 1, UnownPuzzle.CELLS do
    if (pieces[cell] or 0) ~= UnownPuzzle.SOLVED[cell] then return false end
  end
  return true
end

-- .d_up / .d_down / .d_left / .d_right, each transcribed as its own list of
-- refusals.  They are NOT a rectangle: row 5 has only its two end cells (the
-- START>CANCEL box occupies the middle of it), so left from cell 35 jumps
-- straight to 30 and right from 30 jumps straight to 35, and row 4's four
-- interior cells cannot go down at all.
--
-- Returns the new position, or nil when the move is refused (`ret` in the ASM,
-- which also skips the cursor SFX).
function UnownPuzzle.moveCursor(position, direction)
  local pos = position or 0
  if direction == "up" then
    if pos < puzcoord(1, 0) then return nil end
    return pos - UnownPuzzle.COLUMNS
  elseif direction == "down" then
    for col = 1, 4 do
      if pos == puzcoord(4, col) then return nil end
    end
    if pos >= puzcoord(5, 0) then return nil end
    return pos + UnownPuzzle.COLUMNS
  elseif direction == "left" then
    if pos == 0 then return nil end
    for row = 1, 5 do
      if pos == puzcoord(row, 0) then return nil end
    end
    if pos == puzcoord(5, 5) then return puzcoord(5, 0) end
    return pos - 1
  elseif direction == "right" then
    for row = 0, 5 do
      if pos == puzcoord(row, 5) then return nil end
    end
    if pos == puzcoord(5, 0) then return puzcoord(5, 5) end
    return pos + 1
  end
  return nil
end

-- UnownPuzzleCoordData's `dwcoord` column: cell i is a 3x3 tile block whose
-- top-left corner is (1 + 3 * column, 3 * row).
function UnownPuzzle.cellTile(cell)
  local row = math.floor(cell / UnownPuzzle.COLUMNS)
  local col = cell % UnownPuzzle.COLUMNS
  return 1 + col * UnownPuzzle.PIECE_TILES, row * UnownPuzzle.PIECE_TILES
end

-- The same table's "vacant tile" column: the interior 4x4 clears to
-- PUZZLE_VOID, everything on the ring clears back to PUZZLE_BORDER.
function UnownPuzzle.vacantTile(cell)
  local row = math.floor(cell / UnownPuzzle.COLUMNS)
  local col = cell % UnownPuzzle.COLUMNS
  if row >= 1 and row <= 4 and col >= 1 and col <= 4 then
    return UnownPuzzle.VOID_TILE
  end
  return UnownPuzzle.BORDER_TILE
end

-- ------------------------------------------------------------- the screen
--
-- opts: puzzle (the UNOWNPUZZLE_* id the script's `setval` carried), save,
-- random(n) -> 0..n-1, onClose(solved).
function UnownPuzzle.new(game, opts)
  opts = opts or {}
  local self = setmetatable({}, UnownPuzzle)
  self.game = game
  self.onClose = opts.onClose
  self.puzzle = opts.puzzle or 0
  self.random = opts.random or function(n)
    if love and love.math and love.math.random then
      return love.math.random(n) - 1
    end
    return math.random(n) - 1
  end
  -- `ld hl, STARTOF("Miscellaneous") ... call ByteFill` zeroes the whole block
  -- wPuzzlePieces lives in, so every run starts from a clean board and a
  -- cursor at cell 0.
  self.pieces = UnownPuzzle.deal(self.random)
  self.cursor = 0
  self.holding = false
  self.held = 0
  self.solved = false
  self.frame = 0
  self.repeatLeft = 0
  self.lastDirection = nil
  -- The solve fanfare parks on SimpleWaitPressAorB before the screen closes.
  self.waiting = false
  self:loadGfx()
  return self
end

function UnownPuzzle:gfx()
  local data = self.game and self.game.data
  local menu = data and data.gen2MenuGfx
  return menu and menu.unownPuzzle or nil
end

function UnownPuzzle:loadGfx()
  local gfx = self:gfx()
  -- The palettes stand on their own: the board is coloured even on the
  -- art-less degrade, because the cart's colour comes from _CGB_UnownPuzzle
  -- and not from any sheet.
  self.palette = (gfx and gfx.palette) or UnownPuzzle.PALETTE
  self.cursorPalette = (gfx and gfx.cursorPalette)
    or UnownPuzzle.CURSOR_PALETTE
  if not gfx then return end
  self.chrome = TileSheet.new({
    path = gfx.chrome,
    wide = gfx.chromeTiles or 19,
    firstTile = gfx.chromeFirstTile or 0xed,
    palette = self.palette,
  })
  -- `and` would truncate pcall's second return, so the two loads cannot fold
  -- into one-liners.
  local path = gfx.pictures and gfx.pictures[(self.puzzle % 4) + 1]
  if path then
    local ok, image = pcall(Assets.image, path)
    if ok and image then self.picture = image end
  end
  if gfx.cursor then
    local ok, image = pcall(Assets.image, gfx.cursor)
    if ok and image then self.cursorSheet = image end
  end
end

function UnownPuzzle:sfx(name)
  local data = self.game and self.game.data
  if data then Sound.play(data, name) end
end

-- UnownPuzzle_CheckCurrentTileOccupancy: `wPuzzlePieces + wUnownPuzzleCursorPosition`.
function UnownPuzzle:occupant(cell)
  return self.pieces[(cell or self.cursor) + 1] or 0
end

-- UnownPuzzle_A.  Two arms, and both of them refuse loudly rather than
-- silently: an empty cell with nothing held, or an occupied cell with a panel
-- held, is UnownPuzzle_InvalidAction (SFX_WRONG) and nothing moves.
function UnownPuzzle:pressA()
  if not self.holding then
    local piece = self:occupant()
    if piece == 0 then
      self:sfx(SFX_WRONG)
      return
    end
    self:sfx(SFX_PICK_UP)
    self.pieces[self.cursor + 1] = 0
    self.held = piece
    self.holding = true
    return
  end
  if self:occupant() ~= 0 then
    self:sfx(SFX_WRONG)
    return
  end
  self:sfx(SFX_PUT_DOWN)
  self.pieces[self.cursor + 1] = self.held
  self.held = 0
  self.holding = false
  if not UnownPuzzle.isSolved(self.pieces) then return end
  -- The solve: the caption is blanked (PlaceStartCancelBoxBorder is called
  -- again WITHOUT PlaceStartCancelBox), the cursor is cleared, and the screen
  -- holds on the fanfare until A or B.
  self.solved = true
  self.waiting = true
  self:sfx(SFX_SOLVED)
end

function UnownPuzzle:quit()
  if self.closed then return end
  self.closed = true
  if self.onClose then self.onClose(self.solved) end
end

-- .done_joypad: the SFX depends on whether a panel is riding the cursor, and
-- it only plays on a move that actually happened.
function UnownPuzzle:step(direction)
  local target = UnownPuzzle.moveCursor(self.cursor, direction)
  if not target then return false end
  self.cursor = target
  self:sfx(self.holding and SFX_MOVE_PIECE or SFX_MOVE_CURSOR)
  return true
end

local DIRECTIONS = { "up", "down", "left", "right" }

function UnownPuzzle:update(_dt)
  self.frame = self.frame + 1
  local input = self.game and self.game.input
  if not input then return end
  if self.waiting then
    -- SimpleWaitPressAorB, then UnownPuzzle_Quit falls through with
    -- wSolvedUnownPuzzle already TRUE.
    if input:wasPressed("a") or input:wasPressed("b") then self:quit() end
    return
  end
  if input:wasPressed("start") then
    self:quit()
    return
  end
  if input:wasPressed("a") then
    self:pressA()
    return
  end
  -- The d-pad repeat, on JoyTextDelay's own 15-then-5 frame counts.
  local held = nil
  for _, direction in ipairs(DIRECTIONS) do
    if input:wasPressed(direction) then
      held = direction
      self.lastDirection = direction
      self.repeatLeft = REPEAT_DELAY
      self:step(direction)
      return
    end
  end
  for _, direction in ipairs(DIRECTIONS) do
    if input.isDown and input:isDown(direction) then held = held or direction end
  end
  if held ~= self.lastDirection then
    self.lastDirection = held
    self.repeatLeft = REPEAT_DELAY
    return
  end
  if not held then return end
  self.repeatLeft = self.repeatLeft - 1
  if self.repeatLeft > 0 then return end
  self.repeatLeft = REPEAT_RATE
  self:step(held)
end

-- ---------------------------------------------------------------- drawing
--
-- Everything here is 8px tile coordinates, and everything without art falls
-- back to a labelled cell rather than to nothing.
local BOX_X, BOX_Y = 4, 15
local BOX_RIGHT = 15
local CAPTION_X, CAPTION_Y = 5, 16
local CAPTION_TILES = 10
local CAPTION_FIRST = 0xf6
-- PlaceStartCancelBoxBorder's six corner and edge tiles.
local BOX_TOP_LEFT, BOX_TOP, BOX_TOP_RIGHT = 0xf0, 0xf1, 0xf2
local BOX_SIDE, BOX_BOTTOM_LEFT, BOX_BOTTOM_RIGHT = 0xf3, 0xf4, 0xf5

-- The board is BROWN, not grey.  _CGB_UnownPuzzle (engine/gfx/cgb_layouts.asm)
-- runs CopyFourPalettes over PalPacket_UnownPuzzle, which is
-- PREDEFPAL_UNOWN_PUZZLE four times, and then WipeAttrmap puts every tile on BG
-- palette 0 -- so the whole screen wears white / tan / dark brown / black.  The
-- later `ld a, $e4 / call DmgToCgbBGPals` is the IDENTITY reorder of that
-- palette (home/palettes.asm CopyPals), not a grey ramp of its own.
local function paletteColor(colors, index)
  local c = GbcPalette.color(colors, index)
  return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
end

function UnownPuzzle:drawTile(tile, tx, ty)
  if self.chrome and self.chrome:draw(tile, tx, ty) then return true end
  local G = love.graphics
  if tile == UnownPuzzle.VOID_TILE then
    G.setColor(paletteColor(self.palette, 1))
  else
    G.setColor(paletteColor(self.palette, 3))
  end
  G.rectangle("fill", tx * 8, ty * 8, 8, 8)
  G.setColor(0, 0, 0, 1)
  return false
end

function UnownPuzzle:fillCell(cell, tile)
  local tx, ty = UnownPuzzle.cellTile(cell)
  for row = 0, UnownPuzzle.PIECE_TILES - 1 do
    for col = 0, UnownPuzzle.PIECE_TILES - 1 do
      self:drawTile(tile, tx + col, ty + row)
    end
  end
end

-- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is
-- just the panel's row and column in the 4x4 picture.
function UnownPuzzle:drawPiece(piece, tx, ty)
  local size = UnownPuzzle.PIECE_TILES * 8
  if self.picture then
    local index = piece - 1
    local sx = (index % UnownPuzzle.PIECES_WIDE) * size
    local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size
    local w, h = self.picture:getDimensions()
    local quad = love.graphics.newQuad(sx, sy, size, size, w, h)
    love.graphics.setColor(1, 1, 1, 1)
    -- The four pictures are drawn out of colours 1 and 2 alone, so binding BG
    -- palette 0 here is what turns the panels tan and dark brown.
    local function body()
      love.graphics.draw(self.picture, quad, tx * 8, ty * 8)
    end
    if self.palette and GbcPalette.available() then
      GbcPalette.with(self.palette, body)
    else
      body()
    end
    love.graphics.setColor(0, 0, 0, 1)
    return
  end
  local G = love.graphics
  G.setColor(paletteColor(self.palette, 2))
  G.rectangle("fill", tx * 8, ty * 8, size, size)
  G.setColor(0, 0, 0, 1)
  G.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5, size - 1, size - 1)
  Chrome.print(tostring(piece), tx + 1, ty + 1)
end

-- The cursor's four OBJ tiles, mirrored into a 3x3 bracket by
-- .OAM_NotHoldingPiece: tile 0 is the corner, 1 the top and bottom edge, 2 the
-- left and right edge, 3 the centre.
local CURSOR_CELLS = {
  { 0, 0, 0, false, false }, { 1, 1, 0, false, false },
  { 2, 0, 0, true, false },
  { 0, 0, 1, false, false }, { 1, 3, 1, false, false },
  { 2, 0, 1, true, false },
  { 0, 0, 2, false, true }, { 1, 1, 2, false, true },
  { 2, 0, 2, true, true },
}

function UnownPuzzle:drawCursor(tx, ty)
  local G = love.graphics
  if not self.cursorSheet then
    G.setColor(paletteColor(self.cursorPalette, 4))
    G.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5, 23, 23)
    G.setColor(0, 0, 0, 1)
    return
  end
  local w, h = self.cursorSheet:getDimensions()
  G.setColor(1, 1, 1, 1)
  local function body()
    for _, spec in ipairs(CURSOR_CELLS) do
      local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4],
        spec[5]
      local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h)
      local sx, sy = flipX and -1 or 1, flipY and -1 or 1
      local ox = (tx + col) * 8 + (flipX and 8 or 0)
      local oy = (ty + row) * 8 + (flipY and 8 or 0)
      G.draw(self.cursorSheet, quad, ox, oy, 0, sx, sy)
    end
  end
  -- OBJ palette 0, whose colours 0 and 3 are both the red _CGB_UnownPuzzle
  -- forces in: the bracket's sheet uses only those two, so it draws red.
  if self.cursorPalette and GbcPalette.available() then
    GbcPalette.with(self.cursorPalette, body)
  else
    body()
  end
  G.setColor(0, 0, 0, 1)
end

function UnownPuzzle:drawStartCancel()
  self:drawTile(BOX_TOP_LEFT, BOX_X, BOX_Y)
  for tx = BOX_X + 1, BOX_RIGHT - 1 do self:drawTile(BOX_TOP, tx, BOX_Y) end
  self:drawTile(BOX_TOP_RIGHT, BOX_RIGHT, BOX_Y)
  self:drawTile(BOX_SIDE, BOX_X, BOX_Y + 1)
  for tx = BOX_X + 1, BOX_RIGHT - 1 do
    self:drawTile(UnownPuzzle.VOID_TILE, tx, BOX_Y + 1)
  end
  self:drawTile(BOX_SIDE, BOX_RIGHT, BOX_Y + 1)
  self:drawTile(BOX_BOTTOM_LEFT, BOX_X, BOX_Y + 2)
  for tx = BOX_X + 1, BOX_RIGHT - 1 do self:drawTile(BOX_TOP, tx, BOX_Y + 2) end
  self:drawTile(BOX_BOTTOM_RIGHT, BOX_RIGHT, BOX_Y + 2)
  -- On the solve the border is redrawn without the caption, so the box empties.
  if self.solved then return end
  local drew = false
  if self.chrome then
    drew = self.chrome:available()
    for i = 0, CAPTION_TILES - 1 do
      self.chrome:draw(CAPTION_FIRST + i, CAPTION_X + i, CAPTION_Y)
    end
  end
  if not drew then
    Chrome.print(Strings(UnownPuzzle.CAPTION), CAPTION_X, CAPTION_Y)
  end
end

function UnownPuzzle:drawPanel()
  Chrome.clear()
  for ty = 0, Chrome.SCREEN_H - 1 do
    for tx = 0, Chrome.SCREEN_W - 1 do
      self:drawTile(UnownPuzzle.BORDER_TILE, tx, ty)
    end
  end
  for cell = 0, UnownPuzzle.CELLS - 1 do
    local tx, ty = UnownPuzzle.cellTile(cell)
    local piece = self:occupant(cell)
    if piece ~= 0 then
      self:drawPiece(piece, tx, ty)
    else
      self:fillCell(cell, UnownPuzzle.vacantTile(cell))
    end
  end
  self:drawStartCancel()
  if self.solved then return end
  local tx, ty = UnownPuzzle.cellTile(self.cursor)
  if self.holding then
    -- .OAM_HoldingPiece: the panel rides the cursor and is drawn every frame.
    if self.held ~= 0 then self:drawPiece(self.held, tx, ty) end
  elseif math.floor(self.frame / 16) % 2 == 0 then
    -- `ldh a, [hVBlankCounter] / and $10`: sixteen frames on, sixteen off.
    self:drawCursor(tx, ty)
  end
end

function UnownPuzzle:draw()
  self:drawPanel()
end

-- The puzzle owns the whole screen: engine/games/unown_puzzle.asm:11 is
-- ClearBGPalettes / ClearTilemap, so nothing of the map survives behind it and
-- the PUZZLE_BORDER black below is the real surround, not a letterbox filler.
function UnownPuzzle:drawsWidescreen() return true end

function UnownPuzzle:drawWidescreen(winW, winH)
  local G = love.graphics
  Chrome.letterbox(winW, winH, 0, 0, 0)
  local scale = Chrome.fitScale(winW, winH)
  G.push()
  G.translate(Chrome.fitOrigin(winW, winH, scale))
  G.scale(scale, scale)
  self:drawPanel()
  G.pop()
end

return UnownPuzzle
