mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 20:00:51 +02:00
341 lines
14 KiB
Lua
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
|