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

StadiumStage.lua

lib/StadiumStage.lua13.8 KB1006447336ff95a8
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
-- The B rungs: the two discs the fight is staged on.
--
-- Where an A rung puts the fight on the MAP -- real ground, whatever the
-- route happens to look like -- a B rung puts it on two platforms against
-- the sky and draws no map at all.
--
-- ------- one stage, two rungs
--
-- The discs do not know what is standing on them. 2D-3D B stands the Game
-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium
-- models, and this file is identical for both: it draws two platforms at two
-- cells and sizes each to whatever footprint it is given. That is why the
-- flat disc rung cost a value in the 3D-BTL ladder and nothing else.
--
-- ------- why this is a rung and not a fix
--
-- Staging on the map is the better picture when the map cooperates, and it
-- often does not. Half of Kanto's interiors are furniture; a cave floor can
-- be nothing but two-cell corridors; some maps have nowhere a fight can be
-- SEEN from a low camera and are declined outright (see BattleArena), which
-- drops the player back to the flat battle screen with no warning. And even
-- where a spot exists, the ground behind the foe is a hedge or a shop counter
-- rather than anything a battle wants behind it.
--
-- Discs have none of those problems, because the stage is CARRIED rather than
-- found: it works on every map, in every building, at every step, and the
-- framing is the same every time. What it gives up is the thing STADIUM A is
-- for -- fighting somewhere real.
--
-- ------- what stays
--
-- The sky, and the light. A battle outdoors is under the hour's own sky, with
-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a
-- dressed one); a battle in a cave or a room is under that place's own void
-- and its own neutral light, exactly as the map itself would be. So the mode
-- is abstracted from the GROUND, not from the world -- walk into a cave at
-- midnight and the fight looks like a cave at midnight.
--
-- And the framing. The camera, the pins, the HUDs, the text box, the move
-- animations and the depth of field are all identical, because every one of
-- them is hung off the arena's CELLS rather than off what is under them. That
-- is the same reason STADIUM A could be an option on the mode rather than a
-- second mode, and it is why this file is a few hundred lines and not a few
-- thousand.

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

local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")

local StadiumStage = {}

local floor = math.floor
local sin, cos = math.sin, math.cos
local pi = math.pi

-- ------- the shape of a disc
--
-- Radius in world pixels, where a map cell is 16 and the two mons stand three
-- cells apart.
--
-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size,
-- because the set's footprints run nearly tenfold: a Caterpie is under four
-- world pixels across and Moltres, wings out, is twenty-six. One radius for
-- both is either a dinner plate under the caterpillar or a doily under the
-- bird.
--
-- RADIUS is the floor, and it is the number most species land on -- it is a
-- little over the map cell a Pokemon is sized to cover, which is the
-- proportion the Game Boy's own battle platforms have. PAD is the margin
-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from
-- being handed something the frame cannot hold.
--
-- These are the FULL radius, out to where the fade has finished; the solid
-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a
-- mon's own footprint fits inside that centre rather than out over the
-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has
-- solid ground under its edges.
StadiumStage.RADIUS = 18
StadiumStage.MAX_RADIUS = 34
StadiumStage.PAD = 1.8

-- The platform for a mon of this footprint. `r` may be nil -- nothing is
-- standing there yet, which is every frame of the send-out before the model
-- appears, and it is also the whole of the flat 2D-3D B rung, where a
-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is
-- already a little over that. Either way the platform is the plain one, and
-- it has to be there BEFORE the Pokemon lands on it.
function StadiumStage.radiusFor(r)
  local want = (r or 0) * StadiumStage.PAD
  if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end
  if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end
  return want
end

-- Per-vertex shading, in the same terms StadiumRig lights the models with, so
-- a disc and the Pokemon standing on it agree about where the sun is. Fitted
-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each
-- axis term is half the spread between that axis's two faces.
local SHADE_BASE = 0.7725
local SHADE_X = 0.06
local SHADE_Y = 0.225
local SHADE_Z = 0.11

local function shadeFor(nx, ny, nz)
  local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z
  if s < 0.30 then return 0.30 end
  if s > 1.00 then return 1.00 end
  return s
end

-- ------- the texture
--
-- The platform is a FLAT painted disc that fades out at its rim -- no rim
-- wall, no thickness, the whole thing carried in one texture on one quad
-- lying on the ground plane. That is what the Game Boy's own battle
-- platforms are, and it is what keeps the stage from competing with the
-- Pokemon standing on it.
--
-- ------- why the fade is DITHERED
--
-- The scene shader discards any texel under half alpha outright (it has to:
-- that is what keeps a sprite's transparent corners out of the depth buffer).
-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut
-- at wherever the ramp crosses 0.5, which is the one thing this must not be.
--
-- The fade is therefore an ORDERED DITHER baked into the texture's alpha:
-- every texel is fully on or fully off, and the proportion that are on falls
-- away toward the rim. It is the same trick the sky already uses for its
-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any
-- other pass, and on a mode built out of visible texels it reads as intended
-- rather than as a limitation.
--
-- The COLOUR is deliberately neutral. Everything the shader does to it after
-- this is the environment's: Voxel3D.tint carries the hour outdoors and the
-- room's own flat light indoors, and the shadow pass darkens whatever the
-- Pokemon standing on it occludes. So one texture is a sunlit platform, a
-- dusk platform and a cave platform, without a variant for each.
StadiumStage.TEX = 128

-- Where the solid centre ends, as a fraction of the disc's radius. Inside
-- this everything is opaque; from here to the rim the dither thins out.
StadiumStage.SOLID = 0.76

local TOP = { 0.74, 0.71, 0.63 }
local TOP_ALT = { 0.67, 0.64, 0.57 }

local texture = nil

-- A small deterministic scatter, for the surface itself. Not a random one: an
-- authored constant that happens to look unpatterned is worth more here than
-- a seed, because it can never change under a different Lua.
--
-- Quantised into blocks, so the surface reads as TEXELS rather than as noise.
-- Per-pixel it came out as a fine mottle that fought the dithered rim for
-- attention -- and the rim is the thing worth looking at. At this size the
-- grain is roughly the size of the voxels everywhere else in the mode.
StadiumStage.GRAIN = 4

local function grain(x, y)
  local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN
  local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN
  local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100
  return v < 34
end

-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than
-- random because a random dither crawls: this pattern is fixed in the
-- texture, so the fade holds still while the camera drifts across it.
local BAYER = {
  { 0, 32,  8, 40,  2, 34, 10, 42 },
  { 48, 16, 56, 24, 50, 18, 58, 26 },
  { 12, 44,  4, 36, 14, 46,  6, 38 },
  { 60, 28, 52, 20, 62, 30, 54, 22 },
  {  3, 35, 11, 43,  1, 33,  9, 41 },
  { 51, 19, 59, 27, 49, 17, 57, 25 },
  { 15, 47,  7, 39, 13, 45,  5, 37 },
  { 63, 31, 55, 23, 61, 29, 53, 21 },
}

function StadiumStage.texture()
  if texture ~= nil then return texture or nil end
  local ok, img = pcall(function()
    local n = StadiumStage.TEX
    local data = love.image.newImageData(n, n)
    local half = (n - 1) / 2
    local solid = StadiumStage.SOLID
    for y = 0, n - 1 do
      local dy = (y - half) / half
      for x = 0, n - 1 do
        local dx = (x - half) / half
        local d = (dx * dx + dy * dy) ^ 0.5
        -- how much of this texel's neighbourhood should survive: everything
        -- inside the solid core, nothing past the rim, and a smooth ramp
        -- between the two that the dither turns into a stipple
        local cover
        if d <= solid then
          cover = 1.0
        elseif d >= 1.0 then
          cover = 0.0
        else
          local t = (d - solid) / (1.0 - solid)
          cover = 1.0 - t * t * (3 - 2 * t)     -- smoothstep, falling
        end
        local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64
        local a = (cover > threshold) and 1 or 0
        local c = grain(x, y) and TOP_ALT or TOP
        data:setPixel(x, y, c[1], c[2], c[3], a)
      end
    end
    local image = love.graphics.newImage(data)
    -- nearest, like every other texture in this mode: the grain and the
    -- stipple are both meant to read as texels. Clamped rather than
    -- repeating now that one texture covers the whole disc.
    image:setFilter("nearest", "nearest")
    image:setWrap("clampzero", "clampzero")
    return image
  end)
  texture = (ok and img) or false
  return texture or nil
end

-- ------- the mesh
--
-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with
-- the whole texture stretched across it. The DISC is the texture's business,
-- not the geometry's -- everything outside the painted circle is alpha the
-- shader discards -- which is what "a flat texture that fades out at the
-- edges" means and what makes this four vertices rather than a hundred and
-- fifty.
--
-- Shaded as a face pointing straight up, because it is one.

local mesh = nil

local function build()
  local s = shadeFor(0, 1, 0)
  local verts = {
    { -1, 0, -1, 0, 0, s },
    {  1, 0, -1, 1, 0, s },
    {  1, 0,  1, 1, 1, s },
    { -1, 0,  1, 0, 1, s },
  }
  return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 })
end

function StadiumStage.mesh()
  if mesh == nil then mesh = build() or false end
  return mesh or nil
end

function StadiumStage.invalidate()
  if texture and texture.release then pcall(texture.release, texture) end
  if mesh and mesh.release then pcall(mesh.release, mesh) end
  texture, mesh = nil, nil
end

-- How far under the ground plane the disc actually sits. A hair, and only so
-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not
-- coplanar with it and left to the depth buffer's mercy.
StadiumStage.SINK = 0.06

-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon
-- placed at that same height stands ON it rather than in it.
function StadiumStage.matrix(x, groundY, z, radius)
  radius = radius or StadiumStage.RADIUS
  return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z),
                  Mat4.scale(radius, 1, radius))
end

-- The two platforms this frame, as (side, matrix) -- shared by the camera's
-- pass and the sun's, so the two can never disagree about where they are.
local function each(arena, groundY, fn)
  local ok, Stadium = pcall(V.require, "Stadium")
  for _, side in ipairs({ "enemy", "player" }) do
    local cell = arena[side]
    if cell then
      local footprint = ok and Stadium and Stadium.footprint(side) or nil
      fn(StadiumStage.matrix(cell[1], groundY, cell[2],
                             StadiumStage.radiusFor(footprint)))
    end
  end
end

-- ------- the synthetic arena
--
-- A B rung does not search the map, because it does not stand on it. The
-- arena is the same WIDE shape every other staged fight uses -- so the two
-- cells are three apart down the middle and BattleCam frames them exactly as
-- it always has -- just placed at a fixed spot rather than a found one.
--
-- Away from the origin on purpose. The coordinates run through the camera
-- solve, the sun's frustum fit and the projection to Game Boy pixels, and
-- putting a stage at (0, 0) is the kind of thing that hides a sign error for
-- months.
StadiumStage.ORIGIN = { 16, 16 }

function StadiumStage.arena(map)
  local BattleArena = V.require("BattleArena")
  local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2],
                               "wide")
  if not arena then return nil end
  -- the map is carried for its SKY and its palette only -- what kind of place
  -- the fight is happening in -- never for its geometry
  arena.map = map
  arena.discs = true
  return arena
end

-- ------- the draws

-- The discs, in the main pass. No wireframe: everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that, and a
-- disc is a turned solid with no grid to draw.
function StadiumStage.draw(arena, groundY)
  if not (arena and arena.discs) then return end
  local m = StadiumStage.mesh()
  local tex = StadiumStage.texture()
  if not (m and tex) then return end
  Voxel3D.seams(false)
  Voxel3D.glass(false)
  each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end)
  Voxel3D.glass(true)
  Voxel3D.seams(true)
end

-- And into the sun, so the two Pokemon put real shadows on the platforms they
-- are standing on. Without this the shadow map is empty where the discs are
-- and a mon casts onto nothing at all -- which, with no ground behind it
-- either, reads as the pair floating.
function StadiumStage.cast(shadowMap, arena, groundY)
  if not (arena and arena.discs and shadowMap) then return end
  local m = StadiumStage.mesh()
  local tex = StadiumStage.texture()
  if not (m and tex) then return end
  each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end)
end

return StadiumStage