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

BattleHud.lua

lib/BattleHud.lua8.6 KB100644776af0f01c
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
-- Overworld battles: the HUD's footing on a world that is not white.
--
-- Gen 1 draws its battle HUDs as black glyphs and bar tiles straight onto
-- the white field, with no box around them -- the field IS the backing. Take
-- the field away and put a route underneath and the name, the level and the
-- HP numbers are black on grass, which is not readable.
--
-- So each HUD block gets a panel: the world behind it, blurred to frosted
-- glass and laid back down translucent, with a tint that pushes it away from
-- whatever colour the text is about to be. Frosted rather than opaque
-- because the point of the mode is that you can see where you are standing,
-- and an opaque slab in the corner of the frame is the white field back
-- again by another name.
--
-- The ink does NOT change. There was a pass here that measured each panel's
-- average brightness and flipped the glyphs to white over a dark one, with
-- hysteresis so a drifting camera could not strobe them. It worked, and it
-- was still wrong: the battle menu is the one part of the frame the player
-- reads constantly, and having its colour depend on what the camera happens
-- to be pointing at makes it an unreliable piece of furniture. Gen 1's
-- battle text is black, so it is black -- and the panel's tint is what
-- earns that its contrast, on a cave floor as much as on a meadow.
--
-- Removing it also took out a one-pixel GPU readback that ran several times
-- a second purely to answer a question nothing asks any more.

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

local BattleHud = {}

-- How solid the frost is over the world behind it, and how far the tint
-- pushes it toward the far end from the text.
--
-- Both deliberately light. The panel is there to make glyphs legible, not to
-- put a slab in the corner of the frame: at these values the sharp world
-- still reads through it and the blur registers as a pane of glass rather
-- than as a second background.
BattleHud.FROST = 0.55
BattleHud.TINT = 0.26

-- The frost buffer's height; width follows the source's aspect. Small on
-- purpose: the downscale is most of the blur.
BattleHud.FROST_H = 72

local frost, frostW, frostH = nil, 0, 0
local blurA, blurB = nil, nil
local frame = 0

local SHADER = [[
  uniform vec2 dir;
  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
    vec4 sum = Texel(tex, tc) * 0.2270270270;
    sum += (Texel(tex, tc + dir) + Texel(tex, tc - dir)) * 0.1945945946;
    sum += (Texel(tex, tc + 2.0 * dir) + Texel(tex, tc - 2.0 * dir)) * 0.1216216216;
    sum += (Texel(tex, tc + 3.0 * dir) + Texel(tex, tc - 3.0 * dir)) * 0.0540540541;
    sum += (Texel(tex, tc + 4.0 * dir) + Texel(tex, tc - 4.0 * dir)) * 0.0162162162;
    return sum * color;
  }
]]

local shader = nil            -- nil = untried, false = unavailable

local function getShader()
  if shader == nil then
    local ok, sh = pcall(love.graphics.newShader, SHADER)
    shader = (ok and sh) or false
  end
  return shader or nil
end

local function canvasOf(w, h, filter)
  local ok, c = pcall(love.graphics.newCanvas, w, h)
  if not ok then return nil end
  c:setFilter(filter or "linear", filter or "linear")
  return c
end

-- Build (or rebuild) the frosted copy of `src` for this frame.
--
-- Two steps, because one is not enough: the downscale to a 72-row buffer
-- averages the world down to something that no longer reads as terrain, and
-- the separable gaussian over that turns the remaining structure into
-- frosted glass rather than a mosaic of the tiles it came from.
function BattleHud.build(src)
  if not src then return nil end
  local blur = getShader()
  local sw, sh = src:getDimensions()
  if sw <= 0 or sh <= 0 then return nil end
  local h = BattleHud.FROST_H
  local w = math.max(1, math.floor(sw * h / sh + 0.5))
  if not frost or frostW ~= w or frostH ~= h then
    frost = canvasOf(w, h)
    blurA = canvasOf(w, h)
    blurB = canvasOf(w, h)
    if not (frost and blurA and blurB) then
      frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
      return nil
    end
    frostW, frostH = w, h
  end

  local prevCanvas = love.graphics.getCanvas()
  local prevBlend, prevAlpha = love.graphics.getBlendMode()
  local prevFilter = { src:getFilter() }
  src:setFilter("linear", "linear")
  love.graphics.setColor(1, 1, 1, 1)
  love.graphics.setBlendMode("replace", "premultiplied")

  local ok = pcall(function()
    love.graphics.setCanvas(frost)
    love.graphics.draw(src, 0, 0, 0, w / sw, h / sh)
    if blur then
      love.graphics.setShader(blur)
      love.graphics.setCanvas(blurA)
      pcall(blur.send, blur, "dir", { 2.5 / w, 0 })
      love.graphics.draw(frost)
      love.graphics.setCanvas(blurB)
      pcall(blur.send, blur, "dir", { 0, 2.5 / h })
      love.graphics.draw(blurA)
      love.graphics.setShader()
      frost, blurB = blurB, frost   -- the blurred one is the frost now
    end
  end)

  love.graphics.setShader()
  if prevCanvas then
    love.graphics.setCanvas(prevCanvas)
  else
    love.graphics.setCanvas()
  end
  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
  src:setFilter(prevFilter[1] or "nearest", prevFilter[2] or "nearest")
  frame = frame + 1
  return ok and frost or nil
end

function BattleHud.frame()
  return frame
end

-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
-- in the source the frost was built from.
local function frostRect(rect, box)
  local kx = frostW / box.pw
  local ky = frostH / box.ph
  local fx = (box.lx + rect[1] * box.scale) * kx
  local fy = (box.ly + rect[2] * box.scale) * ky
  local fw = rect[3] * box.scale * kx
  local fh = rect[4] * box.scale * ky
  return fx, fy, math.max(1, fw), math.max(1, fh)
end

-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD
-- snapped out to the window's edge has left the GB frame, so it has no GB
-- coordinates to be placed from -- see OverworldBattle.snapRects.
local function frostRectWorld(rect, box)
  local kx = frostW / box.pw
  local ky = frostH / box.ph
  return rect[1] * kx, rect[2] * ky,
         math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
end

-- Which of the two the caller's rects are in. One frost buffer, one panel
-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
-- canvas) or world pixels (rects land in the window-resolution world image).
local function mapper(world)
  return world and frostRectWorld or frostRect
end

-- Draw one HUD panel into the current target, in that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- The tint always pushes toward WHITE, away from the black ink that is about
-- to land on it, so the contrast is guaranteed rather than hoped for -- and
-- it is the whole of what makes a fixed ink colour workable over any ground.
function BattleHud.panel(rect, box, world)
  if not (frost and box and box.scale and box.scale > 0) then return false end
  local fx, fy, fw, fh = mapper(world)(rect, box)
  local ok = pcall(function()
    local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
    love.graphics.setColor(1, 1, 1, BattleHud.FROST)
    love.graphics.draw(frost, quad, rect[1], rect[2], 0,
                       rect[3] / fw, rect[4] / fh)
    love.graphics.setColor(1, 1, 1, BattleHud.TINT)
    love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
    love.graphics.setColor(1, 1, 1, 1)
  end)
  return ok
end

-- ------- the whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
local hudLayer = nil

function BattleHud.layerTexture(w, h, fn)
  if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
    hudLayer = canvasOf(w, h, "nearest")
    if not hudLayer then return nil end
  end
  local g = love.graphics
  local prevCanvas = g.getCanvas()
  local prevBlend, prevAlpha = g.getBlendMode()
  local ok, err = pcall(function()
    g.setCanvas(hudLayer)
    g.clear(0, 0, 0, 0)
    g.setBlendMode("alpha")
    g.setColor(1, 1, 1, 1)
    fn()
  end)
  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
  g.setBlendMode(prevBlend or "alpha", prevAlpha)
  g.setColor(1, 1, 1, 1)
  if not ok then error(err, 0) end
  return hudLayer
end

function BattleHud.invalidate()
  frost, blurA, blurB = nil, nil, nil
  frostW, frostH = 0, 0
  hudLayer = nil
end

return BattleHud