Files
DramaticShapeVoxelMod/lib/StadiumStage.lua
T
2026-08-04 11:30:58 -04:00

341 lines
14 KiB
Lua

-- 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