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

BattlePics.lua

lib/BattlePics.lua14.9 KB100644bca2b53b3b
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
-- Overworld battles: giving a battle pic its paper back.
--
-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
-- engine's decoded PNGs key that shade to alpha 0 -- which was free, because
-- the field behind them was white too. A transparent belly on a white page
-- is a white belly.
--
-- Put a route behind it and the belly is grass. Charizard's chest, the whites
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
-- hole with the world showing through, and the mon reads as a stencil.
--
-- So the paper is put back, and only where the paper was. Which pixels those
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
-- the hardware drew the mon's white belly and the white field behind it with
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
-- image says which was which. There is no distinction to recover; there is one
-- to draw.
--
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
-- can reach is background, and whatever it cannot is paper. What makes that
-- work is where the flood is allowed to start.
--
-- Start it at the image border and it fills everything and answers nothing.
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
-- out between two legs and off the bottom of the frame. Run over all 352 of
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
-- it left every mon a stencil, which is the bug this file exists to fix and
-- for a long time did not.
--
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
-- the left, the right and the top are seeded whole. The sky between a pair of
-- ears reaches the top edge and stays sky; the gap between a body and a raised
-- tail reaches the side and stays gap.
--
-- The BOTTOM is the interesting one, because two completely different things
-- meet the underside of a figure and they have to be told apart.
--
--   A DRAIN is where the drawing simply ran out -- a belly whose white carries
--   on down until the artist stopped, leaking to the outside through the inch
--   between a body and a leg. Seal it: what is above it is the mon.
--
--   A MOUTH is the space BETWEEN two legs, or under an arch. It is background
--   that happens to be enclosed on three sides. Leave it open: the world
--   should show through the gap in a trainer's stride.
--
-- What separates them is how WIDE the opening is, and on this game's art that
-- is not a close call. Measured along the bottom of every battle pic: the
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
-- taken at 6 with room either side rather than tuned to a single sprite.
--
-- Apart from that one number the rule is exact: no pixel is filled for what
-- surrounds it, only because the background provably cannot get to it. And it
-- needs no idea whether it is holding a front pic, a back one or a trainer --
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
-- and they come back untouched because that is what their own shape says, not
-- because they were special-cased.
--
-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a
-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such
-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own
-- slot with its feet flush on the text box (BattleState.backPlacement pins
-- row 96), so the only thing under its lowest row is white box. Nothing can
-- reach it from below, whatever the opening's width, and the caller says so
-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic
-- and becomes exact: paper is whatever the background cannot walk to from the
-- left, the right or the top.
--
-- That is the difference between a Pikachu that reads as a mon and one that
-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong,
-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is
-- shade 0 and every one of them is keyed away, so the figure is a rim with the
-- arena showing through it. Each one also has a wide opening along its bottom,
-- which the drain cut correctly reads as a mouth and the sealed bottom
-- correctly does not. Twelve of this game's 151 back pics turn on it; the
-- other 139 come back byte-identical either way, because they had nothing
-- under them the flood was getting in through.
--
-- The silhouette is untouched, so the mon still cuts cleanly against the
-- world; only its insides stop being see-through.
--
-- Read back off the GPU rather than off the asset, deliberately. What comes
-- back is the pic the engine actually decided to draw -- species palette,
-- forced-mono rebuild, shiny recolour, a mod's replacement art -- so this
-- needs to know nothing about how any of that was arrived at. Once per pic
-- per session, cached on the image itself.

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

local BattlePics = {}

-- Cached by the image the engine handed over, one table per bottom rule --
-- the same pic answers differently sealed and unsealed, and a single table
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
-- so a pic that goes out of scope takes its filled twin with it rather than
-- pinning a texture for the session.
local function newCache()
  return {
    [false] = setmetatable({}, { __mode = "k" }),
    [true]  = setmetatable({}, { __mode = "k" }),
  }
end
local cache = newCache()

-- What an enclosed hole is filled with when the pic itself offers nothing
-- better. White, because white is what the battle field was: this restores the
-- pixel the artist drew and the engine then keyed away, it does not invent a
-- new one.
BattlePics.FILL = { 1, 1, 1, 1 }

-- Anything at or under this alpha counts as keyed-out rather than drawn.
local CUT = 0.5

-- Read the pixels the engine would actually blit. A LOVE Image does not hand
-- its data back, so it is drawn into a canvas of its own size and the canvas
-- is read -- which is also what makes this work for every path that produces
-- a pic, without knowing which one produced this one.
--
-- The canvas is forced to dpiscale = 1, and that is the whole difference
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
-- newImageData reads the magnified copy back at its own PIXEL size. The image
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
-- it trusts getWidth(), and the mon stands on the map nearly three times the
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
-- is already 1 there. Nor did every species, because only a pic with an
-- enclosed hole in it comes back through here at all (see `changed` below) --
-- so a Pidgey came out giant and the mon beside it did not, which is what
-- makes this read as a sprite bug rather than a scale one. See the engine's
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
local function readBack(img)
  local w, h = img:getDimensions()
  if w <= 0 or h <= 0 then return nil end
  local prevCanvas = love.graphics.getCanvas()
  local prevBlend, prevAlpha = love.graphics.getBlendMode()
  local prevR, prevG, prevB, prevA = love.graphics.getColor()
  local data = nil
  local ok = pcall(function()
    local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
    love.graphics.setCanvas(canvas)
    love.graphics.clear(0, 0, 0, 0)
    love.graphics.setBlendMode("replace", "premultiplied")
    love.graphics.setColor(1, 1, 1, 1)
    love.graphics.draw(img, 0, 0)
    love.graphics.setCanvas()
    data = canvas:newImageData()
    if canvas.release then pcall(canvas.release, canvas) end
  end)
  if prevCanvas then
    love.graphics.setCanvas(prevCanvas)
  else
    love.graphics.setCanvas()
  end
  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
  love.graphics.setColor(prevR or 1, prevG or 1, prevB or 1, prevA or 1)
  return ok and data or nil
end

-- The box the artwork actually occupies, or nil for a pic with no ink in it.
--
-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
-- whole rows and columns of nothing around itself. The bottom of THIS box is
-- the cut the rule below turns on, and the bottom of the image is just empty
-- frame some distance under it.
local function inkBounds(data, w, h)
  local x0, y0, x1, y1 = w, h, -1, -1
  for y = 0, h - 1 do
    for x = 0, w - 1 do
      local _, _, _, a = data:getPixel(x, y)
      if a > CUT then
        if x < x0 then x0 = x end
        if x > x1 then x1 = x end
        if y < y0 then y0 = y end
        if y > y1 then y1 = y end
      end
    end
  end
  if x1 < x0 then return nil end
  return x0, y0, x1, y1
end

-- The colour the keyed-away shade would have had: the LIGHTEST colour still
-- standing in the pic.
--
-- Pure white is only the right answer while the pic is still grays, and by the
-- time it reaches here it usually is not. picImage hands a pic over AFTER the
-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the
-- whole screen while the blackout text is up -- and shade 0 travels with the
-- rest. A white belly inside a blacked-out mon would be the one lit thing on a
-- dark screen; inside a warm-palette mon it would be a cold patch the artist
-- never drew.
--
-- So the paper is read off the pic rather than assumed, which needs shade 0 to
-- have survived somewhere in it. It always has: every one of this game's 151
-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a
-- cheek, the white of an eye -- because only the shade-0 pixels the decoder
-- could reach were keyed. So what comes back is the baked shade 0 itself, not
-- an approximation of it, and it tracks every palette the engine picks without
-- being told which one that was.
--
-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps
-- all three channels monotonically, so lightest by sum is lightest full stop.
local function paperColor(data, x0, y0, x1, y1)
  local best, pr, pg, pb = -1, nil, nil, nil
  for y = y0, y1 do
    for x = x0, x1 do
      local r, g, b, a = data:getPixel(x, y)
      if a > CUT then
        local lum = r + g + b
        if lum > best then best, pr, pg, pb = lum, r, g, b end
      end
    end
  end
  if best < 0 then return nil end
  return pr, pg, pb
end

-- The widest opening along the bottom of a figure that still counts as a drain
-- rather than a mouth. See the header for the measurements either side of it.
BattlePics.DRAIN = 6

-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
-- the edges of the artwork's box: the left, the right and the top whole, and
-- along the bottom only those openings wide enough to be background rather
-- than the underside of a figure the drawing ran out of -- or none of them at
-- all, for a pic whose feet are on the text box and which therefore has
-- nothing behind its lowest row to let in.
--
-- Confined to the box as well as seeded from it, so the empty frame under a
-- short pic cannot walk around a sealed drain and come back up through it.
--
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
-- pixels and a keyed-out background is most of them, which is a deeper call
-- chain than is worth risking for no gain.
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
  local outside = {}
  local stack, top = {}, 0
  local function clear(x, y)
    local _, _, _, a = data:getPixel(x, y)
    return a <= CUT
  end
  local function push(x, y)
    if x < x0 or y < y0 or x > x1 or y > y1 then return end
    local key = y * w + x
    if outside[key] then return end
    if not clear(x, y) then return end
    outside[key] = true
    top = top + 1
    stack[top] = key
  end
  for x = x0, x1 do push(x, y0) end
  for y = y0, y1 do
    push(x0, y)
    push(x1, y)
  end
  -- the bottom, run by run: a wide one is the gap between two legs and lets
  -- the world through, a narrow one is where a belly ran out and is sealed.
  -- Skipped whole for a pic on the box, where even the widest of them has
  -- white paper behind it rather than arena.
  if not sealBottom then
    local x = x0
    while x <= x1 do
      if clear(x, y1) then
        local from = x
        while x <= x1 and clear(x, y1) do x = x + 1 end
        if (x - from) > BattlePics.DRAIN then
          for k = from, x - 1 do push(k, y1) end
        end
      else
        x = x + 1
      end
    end
  end
  while top > 0 do
    local key = stack[top]
    top = top - 1
    local x, y = key % w, math.floor(key / w)
    push(x - 1, y)
    push(x + 1, y)
    push(x, y - 1)
    push(x, y + 1)
  end
  return outside
end

-- The pic with its enclosed holes filled, or the pic itself when that could
-- not be done (no pixel access, a driver that refused the readback). Never
-- nil for a non-nil argument: a caller must always have something to draw.
--
-- sealBottom for a pic pinned to the text box rather than standing on the map:
-- see the header. A caller that does not say defaults to the map, which is
-- where all but one of this mod's pics are.
function BattlePics.filled(img, sealBottom)
  if not img then return img end
  sealBottom = sealBottom and true or false
  local slot = cache[sealBottom]
  local hit = slot[img]
  if hit ~= nil then return hit or img end

  local made = nil
  local ok = pcall(function()
    local data = readBack(img)
    if not data then return end
    local w, h = data:getDimensions()
    local x0, y0, x1, y1 = inkBounds(data, w, h)
    if not x0 then return end          -- a pic with nothing drawn in it
    local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
    local fill = BattlePics.FILL
    local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
    local fr = pr or fill[1]
    local fg = pg or fill[2]
    local fb = pb or fill[3]
    local changed = false
    -- only inside the box: everything beyond it is frame the artist never
    -- reached, and filling that would put the mon in a white rectangle
    for y = y0, y1 do
      local row = y * w
      for x = x0, x1 do
        if not outside[row + x] then
          local _, _, _, a = data:getPixel(x, y)
          if a <= CUT then
            data:setPixel(x, y, fr, fg, fb, fill[4])
            changed = true
          end
        end
      end
    end
    -- nothing enclosed: hand the original back rather than a copy of it
    if not changed then return end
    local out = love.graphics.newImage(data)
    out:setFilter("nearest", "nearest")
    made = out
  end)

  slot[img] = (ok and made) or false
  return made or img
end

function BattlePics.invalidate()
  cache = newCache()
end

return BattlePics