DramaticShapeVoxelMod archival mirror · read-only
lib/StadiumScreen.lua

StadiumScreen.lua

lib/StadiumScreen.lua15.4 KB10064423b8202478
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391
-- STADIUM battles: the one-time build, on screen.
--
-- A pushed game state, so it draws in the Game Boy's own 160x144 and stops
-- everything under it -- which is what it should do, because it is doing real
-- work and the player should not be walking around while it happens.
--
-- ------- why a species a frame
--
-- A species takes roughly fifty milliseconds to extract, and there are 151 of
-- them. That is ten seconds, which has to go somewhere. Doing them one per
-- frame puts the whole cost on this screen where it is explained, keeps the
-- bar moving at a visible rate, and leaves the frame free to draw between
-- them. Batching more per frame would finish no sooner -- the work is the
-- same -- and would only make the bar jump.
--
-- ------- what it says
--
-- Three things, and each of them is answering a question the player would
-- otherwise have to guess at while the game sits there:
--
--   WHAT is happening -- "STADIUM EXTRACTION", which is what it is.
--
--   HOW FAR through it is -- a bar, filled by species written rather than by
--   elapsed time, so it cannot lie about the remaining work.
--
--   THAT IT IS ALIVE -- which Pokemon it is on, by name. Not decoration: it
--   is the difference between a progress bar the player trusts and one they
--   suspect has hung, and it costs one lookup a frame.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local StadiumInstall = V.require("StadiumInstall")

local StadiumScreen = {}
StadiumScreen.__index = StadiumScreen

-- The Game Boy frame this draws in.
local W, H = 160, 144

-- How long the finished message stays up before the screen retires itself.
StadiumScreen.HOLD = 1.1

local Font = nil
local function font()
  if Font then return Font end
  local ok, F = pcall(require, "src.render.Font")
  if ok then Font = F end
  return Font
end

-- Black glyphs, because that is the only colour the Game Boy font sheets
-- have -- they are black on transparent, so setColor cannot lighten one.
-- Everything here is therefore laid out dark-on-light.
local function text(str, x, y)
  local F = font()
  if not F then return end
  love.graphics.setColor(0, 0, 0, 1)
  F.draw(str, math.floor(x), math.floor(y))
end

local function centred(str, y)
  local F = font()
  if not F then return end
  text(str, (W - F.width(str)) / 2, y)
end

-- How many glyphs fit across the frame. The font is a fixed eight pixels, so
-- twenty is the line -- and a centred string longer than that does not
-- overflow tidily off one side, it clips off BOTH and loses its first word as
-- well as its last ("that is not a Pokemon Stadium ROM" came out as "at is
-- not a Pokemon").
--
-- Fixed, and it stays fixed: shrinking the text to fit more in was tried and
-- the font will not take it. These are 1-bit 8x8 bitmaps, so a fractional
-- downscale drops whole pixel rows out of every glyph -- at 0.75 the last
-- line of an Android save path came out as mush. Long strings get more LINES
-- instead (see the note layout in draw).
local COLS = 20

-- Break a string into lines that fit, on word boundaries, and never more than
-- `limit` of them.
local function wrapped(str, limit, cols)
  limit = limit or 2
  cols = cols or COLS
  local lines, line = {}, nil
  local function push(text)
    if #lines < limit then lines[#lines + 1] = text end
  end
  for word in tostring(str):gmatch("%S+") do
    local try = line and (line .. " " .. word) or word
    if #try <= cols then
      line = try
    else
      if line then push(line) end
      -- A word longer than the line is BROKEN ACROSS lines rather than cut.
      -- It is always a path, and a path is the one thing here that has to be
      -- readable in full -- an absolute Android save directory runs to
      -- ninety-odd characters with no spaces in it at all, so truncating at
      -- twenty told the player almost nothing.
      while #word > cols do
        push(word:sub(1, cols))
        word = word:sub(cols + 1)
      end
      line = word
    end
    if #lines >= limit then break end
  end
  if line then push(line) end
  return lines
end

-- ------- dex number -> the engine's own species key
--
-- Built once, from the loaded data rather than from a list of names carried
-- here: a list would be a second place for the same 151 facts to live, and
-- would go stale against a mod that renames one.
local dexNames = nil

local function speciesName(dex)
  if not dex then return nil end
  if not dexNames then
    dexNames = {}
    local ok, data = pcall(function()
      return require("src.core.Game").data
    end)
    if ok and data and data.pokemon then
      for key, def in pairs(data.pokemon) do
        if type(def) == "table" and def.dex then dexNames[def.dex] = key end
      end
    end
  end
  return dexNames[dex]
end

-- `adopt` means the caller has ALREADY started the build, or already decided
-- it cannot start -- which is the imported path (StadiumRomPick opens the
-- picked file itself, because love.filesystem cannot read an absolute path).
-- Without it this screen would call begin() on the way in and throw away the
-- job it was pushed to display, or overwrite the failure it was pushed to
-- explain with a fresh "no ROM in baseroms" -- which would be true, and would
-- have nothing to do with what just went wrong.
function StadiumScreen.new(game, adopt)
  return setmetatable({ game = game, hold = 0, started = adopt and true or false,
                        adopted = adopt and true or false }, StadiumScreen)
end

-- ------- the same plate, saying something instead of doing something
--
-- A NOTE: title, a wrapped body, and a key to dismiss it. It exists because
-- the one piece of information a player on a platform with no file dialog
-- actually needs -- the absolute path of the folder to put the cartridge in
-- -- is long, machine-specific, and was only ever written to the console,
-- which nobody on a phone can read.
--
-- Same state shape and the same plate as the build screen, so there is one
-- look and one set of stack manners rather than two.
function StadiumScreen.newNote(game, title, lead, body)
  return setmetatable({ game = game,
                        note = { title = title, lead = lead, body = body } },
                      StadiumScreen)
end

-- Opaque: the loading screen owns the frame, so the map underneath is not
-- drawn and not paying for a render it cannot be seen through.
StadiumScreen.isOpaque = true

function StadiumScreen:enter()
  if self.note or self.adopted then return end
  local ok, err = StadiumInstall.begin()
  self.started = ok and true or false
  if not ok then
    StadiumInstall.status.state = "failed"
    StadiumInstall.status.error = err
  end
end

-- The buttons that dismiss a note. Every face button and START, because the
-- prompt says ANY and a player who has to hunt for the right one on a phone
-- has been lied to.
local DISMISS = { "a", "b", "start", "select" }

function StadiumScreen:update()
  -- ------- a note is dismissed by a BUTTON, not by a key
  --
  -- `onKeyPressed` is the keyboard, and a phone has none: the touch overlay
  -- feeds the engine's Input as virtual buttons (Input.overlayPressed), so a
  -- state that only listens for keys cannot be closed by touch at all. That
  -- stranded a player on this screen with no way off it -- the one screen in
  -- the mod whose entire job is to tell somebody something and then get out
  -- of the way.
  --
  -- Polled here rather than handled as an event because `wasPressed` is the
  -- edge test the engine's own battle screens use, and it is fed by the
  -- keyboard, the gamepad AND the overlay through one path.
  if self.note then
    local input = self.game and self.game.input
    if input and input.wasPressed then
      for _, btn in ipairs(DISMISS) do
        if input:wasPressed(btn) then
          if self.game.stack and self.game.stack:top() == self then
            self.game.stack:pop()
          end
          return
        end
      end
    end
    return
  end
  local status = StadiumInstall.status
  if status.state == "building" then
    if not StadiumInstall.step() then
      -- fell out of building: either finished or failed, both of which hold
      -- for a moment so the player sees which
      self.hold = 0
    end
    return
  end
  self.hold = self.hold + 1 / 60
  -- a failure stays up longer, because it is the one the player has to read
  local wait = StadiumScreen.HOLD
  if status.state == "failed" then
    wait = StadiumScreen.HOLD * 4
  elseif status.wrongVersion then
    -- a warning nobody can read is not a warning
    wait = StadiumScreen.HOLD * 3
  end
  if self.hold >= wait then
    if self.game and self.game.stack and self.game.stack:top() == self then
      self.game.stack:pop()
    end
  end
end

-- Let the player out of a build that has gone wrong, or that they would
-- rather not wait for. Cancelling leaves the packs unbuilt, so the STADIUM
-- rungs stay off the row until the next boot offers again -- which is
-- honest, and better than a half-built set.
local function pop(self)
  if self.game and self.game.stack and self.game.stack:top() == self then
    self.game.stack:pop()
  end
end

function StadiumScreen:onKeyPressed(key)
  -- A note takes any key too. This is the KEYBOARD path and it is not the
  -- one that matters on a phone -- see update, which polls the engine's
  -- Input so the touch overlay's virtual buttons work as well.
  if self.note then pop(self) return true end
  if key == "escape" or key == "x" or key == "backspace" then
    StadiumInstall.cancel()
    if self.game and self.game.stack and self.game.stack:top() == self then
      self.game.stack:pop()
    end
    return true
  end
  return false
end

function StadiumScreen:draw()
  local status = StadiumInstall.status
  love.graphics.setColor(0.93, 0.94, 0.90, 1)
  love.graphics.rectangle("fill", 0, 0, W, H)

  if self.note then
    centred(self.note.title, 12)
    -- The sentence is kept SHORT so the path can have the rest of the plate
    -- at full size. Shrinking the path was tried first and does not survive
    -- the font: these are 1-bit 8x8 bitmaps, so a fractional downscale drops
    -- whole pixel rows out of every glyph and the last line came out as
    -- mush. Nine rows of twenty characters is 180, which is longer than any
    -- real save path, so nothing has to be shrunk to fit.
    local lead = wrapped(self.note.lead or "", 2)
    for i, line in ipairs(lead) do centred(line, 30 + (i - 1) * 10) end
    local y = 30 + #lead * 10 + 6
    for i, line in ipairs(wrapped(self.note.body or "", 9)) do
      centred(line, y + (i - 1) * 9)
    end
    centred("PRESS ANY KEY", 130)
    love.graphics.setColor(1, 1, 1, 1)
    return
  end

  -- One line, and it is the whole heading: eighteen glyphs at the font's
  -- fixed eight pixels is 144 of the frame's 160.
  centred("STADIUM EXTRACTION", 34)

  if status.state == "failed" then
    centred("COULD NOT BUILD", 68)
    local lines = wrapped(status.error or "unknown", 2)
    for i, line in ipairs(lines) do centred(line, 82 + (i - 1) * 10) end
    centred("STADIUM IS OFF", 110)
    love.graphics.setColor(1, 1, 1, 1)
    return
  end

  local done = status.done or 0
  local total = status.total or StadiumInstall.COUNT
  local frac = (total > 0) and (done / total) or 1
  if status.state == "done" then frac = 1 end
  if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end

  -- The bar: a dark frame, an EMPTY interior the same colour as the plate,
  -- and a dark fill growing left to right.
  --
  -- The track has to be the plate's own white rather than a light grey. This
  -- draws inside the Game Boy frame, so the colorization pass quantises
  -- everything here into the four GB shades and paints them -- and a grey
  -- track lands one shade down, which comes out as a bar that is GREEN where
  -- the work is still to do and dark where it is done. The eye reads colour
  -- as the filled part and gets the progress exactly backwards.
  local bx, by, bw, bh = 24, 68, W - 48, 9
  love.graphics.setColor(0.06, 0.05, 0.09, 1)
  love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2)
  love.graphics.setColor(0.93, 0.94, 0.90, 1)
  love.graphics.rectangle("fill", bx, by, bw, bh)
  love.graphics.setColor(0.06, 0.05, 0.09, 1)
  love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh)

  if status.state == "done" then
    centred("READY", 86)
    -- and say so if it was built from something other than the revision every
    -- offset in the reader was measured against: it may look fine, it may be
    -- subtly wrong, and the player is the only one who can swap the file
    if status.wrongVersion then
      centred("NOT US 1.0 --", 104)
      centred("MODELS MAY BE WRONG", 114)
    end
  else
    local name = speciesName(status.species)
    centred(("%d/%d"):format(done, total), 86)
    if name then centred(name, 98) end
  end
  love.graphics.setColor(1, 1, 1, 1)
end

-- ------- when this comes up
--
-- The first frame the player is actually IN the world, rather than at boot.
-- Two reasons. The engine has its own launcher and ROM importer before the
-- game starts, and pushing over those would be fighting them for the screen.
-- And `Game.data` has to be loaded for the species names above to resolve.
--
-- Asked once. If the player cancels, or there is no ROM, this does not come
-- back until the next run -- a loading screen that reappears every time you
-- step outside would be worse than no stadium models.
local asked = false

function StadiumScreen.maybePush()
  if asked then return false end
  local ok, Game = pcall(require, "src.core.Game")
  if not (ok and Game and Game.stack and Game.overworld) then return false end
  if Game.stack:top() ~= Game.overworld then return false end
  asked = true
  if not StadiumInstall.pending() then
    -- Say where to put a cartridge, ONCE, and only when there is nothing to
    -- build from and nothing already built. The two STADIUM rungs are simply
    -- absent in that case (ModSetting.setGate), which is the right thing for
    -- a row to do and tells the player nothing about why -- and the answer
    -- they need is an absolute path that depends on how the game was
    -- installed, so it cannot be written into the options help text.
    if not StadiumInstall.available() then
      -- The IMPORT row is the answer wherever a file dialog can be opened,
      -- and it is the better one: no folder to create, no path to get right,
      -- no restart. The folder is still said, once, for the platforms with no
      -- dialog (Android, a handheld Linux with neither zenity nor kdialog)
      -- and for anyone who would rather drop a file than click through one.
      -- The STADIUM ROM row is on the OPTIONS menu on every platform now, so
      -- point at it rather than reciting a path here: where a file dialog can
      -- be opened it opens one, and where it cannot it shows this same folder
      -- on screen -- which is the part a phone could not otherwise find out.
      local okPick, pick = pcall(V.require, "StadiumRomPick")
      local label = (okPick and pick and pick.LABEL) or "STADIUM ROM"
      local how = (okPick and pick and pick.canDialog())
                  and "opens a file picker" or "says where to put one"
      V.mod.log:info("stadium: no Pokemon Stadium (US) 1.0 ROM found, so the "
                     .. "STADIUM battle rungs are off. OPTIONS -> %s %s; the "
                     .. "folder is %s", label, how, StadiumInstall.romHint())
    end
    return false
  end
  Game.stack:push(StadiumScreen.new(Game))
  return true
end

-- named for the suite, which drives the screen without a boot
function StadiumScreen._reset()
  asked = false
end

return StadiumScreen