Files
DramaticShapeVoxelMod/lib/BattleScene.lua
T
2026-08-08 10:11:00 -04:00

848 lines
41 KiB
Lua

-- Overworld battles: one frame of the arena, as geometry.
--
-- The same world the free-roam mode draws, from a placed camera instead of
-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
-- backdrop reaches the screen through Renderer's worldOverride, the seam a
-- render pipeline's finished world image already composites through, which
-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen
-- then blits over it in the classic letterbox. So the world is as crisp as
-- the free-roam diorama and the pics, HUDs and text box stay exactly the
-- chunky GB art they are.
--
-- Rendering the whole window rather than just the letterbox means the
-- framing has to be split in two. The RIG frames the GB's 160x144 (see
-- BattleCam, which is solved against coordinates in that frame); this
-- module widens the lens by exactly the ratio the window bears to the
-- letterbox, so the letterbox sub-rectangle of what gets rendered is
-- bit-for-bit the framing the rig asked for, and everything outside it is
-- extra picture. That is what lets the two mons be PINNED: their cells
-- project to the same GB coordinates at any window size or zoom.
--
-- Characters are deliberately absent. The overworld cast is culled for the
-- length of the battle (see OverworldBattle), so this pass has terrain,
-- grass and flowers and nothing that walks -- the arena is empty, which is
-- what makes it an arena.
--
-- Everything expensive is shared with the free-roam mode rather than
-- duplicated: the same chunk meshes out of ChunkMesher, the same palette
-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a
-- map already meshed for walking around costs the frame it draws and
-- nothing else.
-- 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 ShadowMap = V.require("ShadowMap")
local ChunkMesher = V.require("ChunkMesher")
local TerrainAtlas = V.require("TerrainAtlas")
local VoxelScene = V.require("VoxelScene")
local BattleCam = V.require("BattleCam")
local BattleBillboard = V.require("BattleBillboard")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local AntiAlias = V.require("AntiAlias")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local BattleScene = {}
-- ------- LET'S GO capture mode's stake in this scene
--
-- One table while a capture session runs, nil otherwise (see
-- lib/CatchThrow.lua, which owns it):
--
-- hidePlayer the player's side stays out of the shot entirely -- no
-- card here, no model (Stadium reads this same table), no
-- pinned back pic (OverworldBattle reads it too)
-- shrink the foe's scale while the ball drinks it in, applied
-- about its chest so it collapses toward the beam
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
-- depth buffer, same flash window, same camera
-- cast(sm) the same ball into the sun's pass
-- sig() a term for the cached shadow signature, so a ball in
-- flight re-casts and a resting scene does not
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
-- BattleState:draw wrap in the GB frame
--
-- It lives HERE, not on OverworldBattle, because every consumer below
-- already requires BattleScene and the one file that writes it requires
-- both -- this is the spot with no require cycle.
BattleScene.capture = nil
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against.
BattleScene.GB_W = 160
BattleScene.GB_H = 144
-- A map cell in world pixels: the overworld square a mon stands on, which is
-- both what the arena is measured in and what a mon is sized to.
BattleScene.CELL = 16
-- How far into black a shadow goes in the arena, against the free-roam
-- mode's own lighter setting.
--
-- Darker on purpose, and only here. Walking around, a shadow is scenery and
-- wants to stay out of the way of reading the map. In a battle it is doing
-- one specific job: the two mons are flat cards, and the ONLY thing telling
-- the eye they are standing on that floor rather than hanging in front of it
-- is the shadow they put on it. A faint one leaves them floating.
BattleScene.SHADOW_ALPHA = 0.68
-- Which rung of the sky ramp an indoor void is painted with. A room has no
-- sky, but it does have somewhere the geometry stops, and leaving that
-- transparent would show the letterbox clear through the gaps.
local INDOOR_SHADE = 4
-- ------- where the GB frame sits inside the window
--
-- Renderer blits worldOverride one canvas pixel to one screen pixel and then
-- blits the 160x144 UI canvas into a centred, integer-scaled letterbox. So
-- these have to agree with Renderer:endFrame exactly, or the pins land off
-- the mons by however much they disagree.
function BattleScene.letterbox()
local Renderer = require("src.render.Renderer")
local pw, ph = BattleScene.pixelSize()
local s = Renderer:fitScale()
return math.floor((pw - BattleScene.GB_W * s) / 2),
math.floor((ph - BattleScene.GB_H * s) / 2),
s, pw, ph
end
-- The window in FRAMEBUFFER pixels, which is what the override blit works
-- in. love.graphics.getDimensions is in LOVE units and differs from this by
-- the display density on mobile.
function BattleScene.pixelSize()
if love.graphics.getPixelDimensions then
local pw, ph = love.graphics.getPixelDimensions()
if pw and ph and pw > 0 and ph > 0 then return pw, ph end
end
return love.graphics.getDimensions()
end
-- Widen the rig's vertical field of view from the GB frame to the whole
-- window, so the letterbox rows show exactly what the rig framed.
--
-- The horizontal falls out of it: at aspect pw/ph the window's half-width is
-- tan(fov/2) * pw/ph, and the letterbox is 160*s of those pw pixels, which
-- works back out to the GB frame's own 160/144. So one scale on the vertical
-- pins both axes.
function BattleScene.letterboxFov(fovGB, ph, s)
local span = BattleScene.GB_H * s
if span <= 0 then return fovGB end
return 2 * math.atan(math.tan(fovGB / 2) * ph / span)
end
-- ------- palette
--
-- The world palette a map draws under, in the shape VoxelScene's colour
-- helpers take. Rebuilt per frame from the overworld state, which is where
-- the engine's own pipeline context gets it too (OverworldController's
-- ctx.paletteFor).
local function paletteFor(state, home)
return function(map)
return PaletteFX.pal(require("src.core.Game").data,
state:paletteNameFor(map or home))
end
end
-- ------- the map the fight is staged on
--
-- Normally the one the player is standing on. An authored arena may name
-- another floor of the same cave or building (see BattleArena), and then the
-- scene is THAT map: its terrain, its palette, its sky. Nothing else in the
-- battle changes -- the fight, the party, the player's own position are all
-- exactly where they were.
--
-- A foreign floor is meshed alone, with no connected neighbours: connections
-- are the player's neighbourhood, and the map the camera has gone to visit is
-- not standing in it. Both maps are kept live so neither the arena's mesh nor
-- the one waiting to be walked back onto is evicted mid-battle.
local function prefetchArena(state, host)
if host == state.map then return VoxelScene.prefetch(state) end
local live = { [host.id] = true, [state.map.id] = true }
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
ChunkMesher.setLive(live)
TerrainAtlas.setLive(live)
ChunkMesher.request(host, false, nil, true)
local terrain, water = ChunkMesher.pair(host, false)
if not terrain then terrain, water = ChunkMesher.pair(host, true) end
return terrain, {}, water, {}
end
-- ------- the sun
--
-- Only has to be drawn once per battle: the arena does not move, and neither
-- does the light. So the signature is the map, the arena and the meshes --
-- not the camera, which is the one thing that IS moving and the one thing
-- the sun does not care about.
-- ------- the two mons, hung on their cells
--
-- The billboard texture is the battle screen's own 160x144 pics layer with
-- one side rendered into it (see OverworldBattle.sideTexture), so the quad is
-- that whole frame stood up on the map -- which is what carries every pic
-- effect the engine applies without any of them being reimplemented here.
--
-- Its size follows from one number: a full 7x7-tile mon covers one overworld
-- square, so a canvas pixel is FULL_W / FULL_PIC world pixels and the card is
-- the canvas at that scale. Its placement follows from the anchor the
-- texture reports -- the column the pic was centred on and the row its feet
-- were put on -- which is translated onto the cell before the card is stood
-- up, so a mon of any size in any pose has its feet on the ground.
-- `mirror` flips the card about its own anchor column. Both mons wear their
-- FRONT pic, which is drawn facing out of the screen -- so dropped into the
-- world unaltered the pair stand back to back, both looking the same way past
-- each other. Mirroring the near one turns it to face the far one, which is
-- what a fight looks like; and because it is a flip about the pic's own
-- centre the feet do not move off the tile.
--
-- The player's TRAINER pic is the exception, and it is exempted below. That
-- one is a BACK view -- the player seen from behind, already turned to face
-- up the field -- so it arrives pointing the right way and mirroring it would
-- turn it around to face the camera it is standing in front of.
local function monMatrix(tex, x, groundY, z, mirror)
local k = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
local w = BattleScene.GB_W * k
local h = BattleScene.GB_H * k
local ox = -((tex.ax / BattleScene.GB_W) - 0.5) * w
local oy = -((BattleScene.GB_H - tex.ay) / BattleScene.GB_H) * h
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1))
if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end
return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
card)
end
-- Every mon that has something to show this frame, as (texture, matrix).
local function monCards(arena, groundY, textures)
local out = {}
if not textures then return out end
local cap = BattleScene.capture
for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy
-- capture mode: the player's side is out of the shot (the seat looks
-- over an empty shoulder), and OverworldBattle.textures already
-- skipped rendering it -- this is the belt to that suspender
if side == "player" and cap and cap.hidePlayer then tex = nil end
if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
-- the foe drinking into the ball: scaled about its own chest, in
-- world space so the composed card matrix needs no decomposition
if side == "enemy" and cap and cap.shrink then
local k = cap.shrink
local ax, ay, az = cell[1], groundY + 8, cell[2]
model = Mat4.mul(
Mat4.mul(Mat4.translate(ax, ay, az),
Mat4.mul(Mat4.scale(k, k, k),
Mat4.translate(-ax, -ay, -az))),
model)
end
out[#out + 1] = { tex = tex.canvas, model = model }
end
end
return out
end
BattleScene.monCards = monCards
-- The MOVE-ANIMATION layer's place in the world: a BILLBOARD facing the
-- eye, for the GB-frame effects texture OverworldBattle.animTexture
-- renders (the engine's own drawAnimLayer, caught on a canvas).
--
-- Effects are 2D drawings like the pics, and the pics' answer holds for
-- them too: a drawing must FACE the eye that is looking (the mon cards
-- yaw toward it per eye -- see monMatrix). So the frame stands on the
-- arena's midpoint, yawed at the eye like the cards are, and the classic
-- layout's two slot marks are pinned where each CELL lands on that plane
-- along this very eye's own ray -- so from the eye that is looking, a
-- burst authored at a slot sits exactly over the mon standing in for it,
-- and a projectile crossing the frame crosses the arena. The vertical
-- scale is the mon cards' own (FULL_W / FULL_PIC), so an effect is sized
-- like the pics it plays over.
--
-- An eye standing (nearly) ON the arena's axis sees the two cells in
-- line and the pinning degenerates; the frame then falls back to the
-- fixed plane through both cells, which that eye views edge-on anyway.
--
-- Reads Voxel3D.eye at CALL time, like the cards -- call it per eye.
-- Returns the model matrix for BattleBillboard's unit card (x -0.5..0.5,
-- y 0..1 up, v flipped), or nil where the anchors are degenerate.
function BattleScene.fxCard(arena, groundY, anchors)
local p, e = anchors.player, anchors.enemy
local dgb = e[1] - p[1]
if math.abs(dgb) < 1 then return nil end
local GW, GH = BattleScene.GB_W, BattleScene.GB_H
local Px, Py, Pz = arena.player[1], groundY, arena.player[2]
local Ex, Ey, Ez = arena.enemy[1], groundY, arena.enemy[2]
local s = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
local Mx, My, Mz = (Px + Ex) / 2, groundY, (Pz + Ez) / 2
local eye = Voxel3D.eye
local yaw = BattleBillboard.yawToward(Mx, Mz, eye)
local nx, nz = math.sin(yaw), math.cos(yaw) -- out of the frame, at the eye
local rx, rz = math.cos(yaw), -math.sin(yaw) -- the frame's own right
-- where a world point sits ON the billboard, as (right, up) coordinates
-- about the midpoint: slid along the eye's ray onto the plane, so the
-- mark and the mon line up from exactly the seat that is looking
local function inPlane(qx_, qy_, qz_)
if eye then
local dqx, dqy, dqz = qx_ - eye[1], qy_ - eye[2], qz_ - eye[3]
local denom = dqx * nx + dqz * nz
if math.abs(denom) > 1e-6 then
local t = ((Mx - eye[1]) * nx + (Mz - eye[3]) * nz) / denom
qx_ = eye[1] + dqx * t
qy_ = eye[2] + dqy * t
qz_ = eye[3] + dqz * t
end
end
return (qx_ - Mx) * rx + (qz_ - Mz) * rz, qy_ - My
end
local pax, pay = inPlane(Px, Py, Pz)
local eax, eay = inPlane(Ex, Ey, Ez)
if math.abs(eax - pax) < 4 then
-- edge-on: the fixed plane through both cells, world-axis mapping
local ux = (Ex - Px) / dgb
local uy = (Ey - Py - s * (p[2] - e[2])) / dgb
local uz = (Ez - Pz) / dgb
local cx = Px + ux * (0.5 * GW - p[1])
local cy = Py + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
local cz = Pz + uz * (0.5 * GW - p[1])
local nl = math.sqrt(ux * ux + uz * uz)
local fx, fz = 0, 1
if nl > 1e-9 then fx, fz = uz / nl, -ux / nl end
return { ux * GW, 0, fx, cx,
uy * GW, s * GH, 0, cy,
uz * GW, 0, fz, cz,
0, 0, 0, 1 }
end
-- in-plane travel per GB pixel of frame x, solved so both marks land:
-- inPlane(gb) = (pax, pay) + U * (gbx - p.x) + (0, s) * (p.y - gby)
local ux = (eax - pax) / dgb
local uy = (eay - pay - s * (p[2] - e[2])) / dgb
local cxp = pax + ux * (0.5 * GW - p[1])
local cyp = pay + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
return { rx * ux * GW, 0, nx, Mx + rx * cxp,
uy * GW, s * GH, 0, My + cyp,
rz * ux * GW, 0, nz, Mz + rz * cxp,
0, 0, 0, 1 }
end
-- The sun has to see the mons too, or they stand on the ground without
-- putting anything on it. They are the one thing in this scene that MOVES,
-- so `token` -- a counter the caller bumps whenever a pic could have changed
-- -- goes in the signature; the terrain half of the answer would otherwise
-- keep a stale pass alive and freeze the shadows in whatever pose they were
-- first drawn in.
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
-- `turn` is in the signature with the corner and the shape: the same corner
-- turned a quarter is a different footprint standing on different ground,
-- and a cast kept from the other one freezes the shadows across it
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
tostring(arena.turn or 0),
tostring(terrain), tostring(token or 0),
-- the cycle keeps running through a fight, and an arena lit
-- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 128) }
-- a capture session's ball moves through the sun's world too; its term
-- is quantised inside sig() so the cache re-renders on real movement
-- and not on every frame the ball rests
local cap = BattleScene.capture
if cap and cap.sig then
local okSig, sig = pcall(cap.sig)
parts[#parts + 1] = okSig and sig or "cap"
end
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
atlasFor, cards, token, host, neighbors,
water, nbWater, groundY)
if not ShadowMap.available() then return end
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
if not ShadowMap.stale(sig) then return end
if not ShadowMap.begin(cx, cy, vw, vh) then return end
-- A DISC RUNG: the two discs are the only ground there is, so they are the
-- only thing the sun has to see besides the Pokemon themselves. Everything
-- below this is a map that is not in the shot.
if arena.discs then
pcall(function()
V.require("StadiumStage").cast(ShadowMap, arena, groundY or 0)
end)
pcall(function() V.require("Stadium").cast(ShadowMap) end)
ShadowMap.finish(sig)
return
end
ShadowMap.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
end
-- the water surface is its own reflective pass now (see Water) and so is
-- no longer inside the terrain mesh; the sun still has to see it, or the
-- light's map has a hole at every lake
ShadowMap.draw(water, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
-- keep contact with their bases instead of starting a bias-width away
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
-- the mons themselves, as the same cards the camera will see. Their alpha
-- is the silhouette, so what lands on the ground is the shape of the
-- Pokemon rather than a blob standing in for one.
-- marked as the CAST, so a fight staged at the water's edge does not lay a
-- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's
-- own floor still takes them, which is the shadow that matters here
ShadowMap.sprites(true)
for _, card in ipairs(cards or {}) do
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
ShadowMap.snug(card.model))
end
ShadowMap.sprites(false)
-- and the STADIUM models, when that rung is the one running. NOT marked
-- as sprites: that flag exists so a flat card's cut-out is kept off the
-- water (see ShadowMap.sprites), and these are real geometry standing in
-- the world -- a Gyarados at the water's edge should put a Gyarados on
-- the water. Un-snugged for the same reason: snug is a bias for a card
-- rooted to the ground plane, and a model has thickness of its own.
pcall(function() V.require("Stadium").cast(ShadowMap) end)
-- the capture session's ball, by the same reasoning: real geometry, its
-- shadow is half of what sells the arc
local cap = BattleScene.capture
if cap and cap.cast then pcall(cap.cast, ShadowMap) end
ShadowMap.finish(sig)
end
-- The height of the arena floor: the ground the two mons stand on. Both
-- cells are open, so they are normally the same; take the player's, which is
-- the one nearer the camera and therefore the one a mismatch would show up
-- against.
function BattleScene.groundY(map, arena)
-- A disc rung's discs are carried, not found: their tops ARE the ground
-- plane, so there is no terrain height to read and reading one would put
-- the stage at whatever elevation the map happens to have at a spot the
-- fight is not actually happening on
if arena and arena.discs then return 0 end
local ok, h = pcall(VoxelScene.groundAt, map,
arena.playerCell[1], arena.playerCell[2])
return (ok and h) or 0
end
-- Where a world point lands in GB frame coordinates under `vp`, or nil when
-- it is behind the camera. This is the function the pins are built on: it
-- takes the window-resolution clip position and divides the letterbox back
-- out of it, so the answer is in the same 160x144 space the battle screen
-- draws its pics in.
function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph)
local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4]
local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8]
local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16]
if cw <= 1e-6 then return nil end
-- viewProjection already flipped clip Y into LOVE's Y-down convention
local px = (cx / cw * 0.5 + 0.5) * pw
local py = (cy / cw * 0.5 + 0.5) * ph
return (px - lx) / s, (py - ly) / s
end
-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} },
-- the two marks in GB coordinates -- or nil when there is nothing to draw
-- yet (the terrain mesh is still building, the driver has no depth support).
-- nil is not a failure: the caller simply leaves the battle screen as the
-- engine drew it for that frame.
-- White, for the hit flash, and how far toward it the card goes.
--
-- The shader replaces the card's colour rather than multiplying it, so at
-- full strength this is the sprite turned into a solid white silhouette --
-- which is what the effect is on a flat GB screen and far too much on a
-- sprite standing in a lit world. Held well short of 1, the mon's own
-- shading still reads through the flash: it looks struck rather than
-- deleted.
BattleScene.FLASH_COLOR = { 1, 1, 1 }
BattleScene.FLASH_STRENGTH = 0.5
-- ------- the tile clock, while the overworld is not the one drawing
--
-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE
-- only advances it from OverworldState:drawWorld -- which runs under dialogs
-- and menus, but not under a battle, because a battle draws instead of the
-- overworld rather than over it. So for the length of a staged fight the
-- counter stood still: the water tiles stopped rotating their pixels and the
-- wave field, which is driven off the same number so the two cannot drift
-- (see Water), stopped with them. A lake in the background of a battle was a
-- photograph.
--
-- Ticked HERE rather than from the mod's update hook, because here is the
-- one place that means "a staged battle is drawing this frame, and the
-- overworld is not". From the update hook the condition would have to be
-- guessed at, and a frame where both ran would double the rate.
local function tickTiles()
local Game = require("src.core.Game")
local ow = Game and Game.overworld
local top = Game and Game.stack and Game.stack:top()
-- during the wipe INTO a battle the overworld can still be the one
-- drawing, and it is ticking the clock itself; two ticks in a frame would
-- run the water at double speed
if top and ow and top == ow then return end
pcall(require("src.render.TileRenderer").tick)
end
function BattleScene.render(state, arena, textures, token)
if not (state and state.map and arena) then return nil end
if not Voxel3D.available() then return nil end
tickTiles()
-- the floor the fight is staged on: normally the player's own, sometimes
-- another floor of the same cave or building (see BattleArena)
local host = arena.map or state.map
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
-- the hour's light reaches the arena exactly as it reaches free-roam: the
-- shared rig follows the clock on an outdoor floor and stays at noon on an
-- indoor one, and the same tint multiplies the staged shot -- with the
-- same window glass on whatever buildings stand in the background
local outdoor = host.def and Map.isOutdoor(host.def) or false
DayNight.applyRig(outdoor)
-- a canopy floor (Viridian Forest) fights under the hour's tint too,
-- with the rig and the void exactly as they were
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host))
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
-- no glint in the arena: the drift is the shot breathing, not the player
-- moving, and a shimmer on background windows would fight the mons
Voxel3D.glassGlint = 0
-- the host floor's atmosphere reaches the staged shot at HALF density --
-- a fight in Viridian Forest sits in the same haze the walk there did,
-- thinned so neither mon goes soft -- and its god rays stay out of it:
-- this camera is low and long, and a bright blade across a combatant
-- reads as a rendering fault, not weather. nil almost everywhere.
local ForestAtmos = V.require("ForestAtmos")
local atmos = ForestAtmos.frame(host)
Voxel3D.fog = atmos and { color = atmos.fog.color,
density = atmos.fog.density * 0.5,
start = atmos.fog.start,
heightK = atmos.fog.heightK } or nil
-- A B RUNG stands the fight on two carried discs against the sky, with no
-- map in the shot at all (see StadiumStage). Everything below still runs --
-- the letterbox, the camera solve, the sun, the pins, the tint, the depth
-- of field -- because none of it is about the terrain; what changes is
-- which geometry the two passes draw.
local discs = arena.discs and true or false
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh, water, nbWater
if discs then
-- and nothing is meshed for a disc fight, which is the other half of why
-- the rung works everywhere: there is no waiting for a chunk to build, so
-- the first frame of the first battle on a cold map is the finished shot
nbMesh, water, nbWater = {}, nil, {}
else
terrain, nbMesh, water, nbWater = prefetchArena(state, host)
if not terrain then return nil end
end
local lx, ly, s, pw, ph = BattleScene.letterbox()
if not (pw > 0 and ph > 0 and s > 0) then return nil end
local palette = paletteFor(state, host)
local function atlasFor(map)
return TerrainAtlas.forMap(map, VoxelScene._modeColors(palette, map))
end
local groundY = BattleScene.groundY(host, arena)
-- A capture session brings a camera of its own: the head-on seat, on
-- the arena's axis looking straight at the foe, in place of the solved
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
-- the pins, the sun, the cards yawing to the eye -- is generic over
-- whichever camera this is.
local cam, pitch, capFrameH
local cap = BattleScene.capture
if cap and cap.rig then
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
end
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
-- the world extents the sun frustum is fitted to; the camera itself is
-- framed by cam.fov, so these only have to describe the ground in shot
-- the player's zoom is part of this: the sun's box is fitted to what the
-- frame holds, so a shot pulled wide has to light the ground it just
-- brought into view rather than the ground the rig alone would have
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
/ (BattleScene.GB_H * s)
local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live
-- before they are built; Voxel3D.eye is set by viewProjection, which
-- beginScene calls -- so a provisional one is taken here for the sun pass
-- and the real one is rebuilt inside the scene below.
Voxel3D.camera = cam
Voxel3D.viewProjection(cx, cy, vw, vh)
local cards = monCards(arena, groundY, textures)
Voxel3D.camera = nil
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
cards, token, host, neighbors, water, nbWater, groundY)
-- An opaque void either way. Outdoors the camera is low enough that the
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
-- of the same ramp, which is a room's "past the wall". Transparent -- the
-- free-roam default -- would let the letterbox clear through wherever the
-- geometry stops.
local sky = VoxelScene.skyColor(host, 1)
or VoxelScene.skyShade(INDOOR_SHADE, 1)
-- On a disc rung the void is not a backdrop behind the scenery -- it IS the
-- scenery, because the map is not drawn. So outdoors it gets the full
-- treatment the free-roam camera gets: the banded gradient and the hour's
-- own sun or moon hanging in it (Voxel3D.beginScene paints those when the
-- sky it is handed carries bands). Indoors there is nothing to dress: a
-- room's void is one flat shade, which is what a room looks like past the
-- wall, and the disc fight in a cave is lit and coloured as that cave.
if discs and VoxelScene.skyColor(host, 1) then
local Sky = V.require("Sky")
local okDress, dressed = pcall(Sky.dress, sky)
if okDress and dressed then sky = dressed end
end
Voxel3D.camera = cam
-- the sun is turned up for the arena and put back afterwards, so the
-- free-roam world it shares this module with keeps its own weight -- and
-- the hour still has the last word: a sunset fades the arena's shadows
-- out and the moon presses more softly, exactly as it does outside
local sunWas = Voxel3D.SHADOW_ALPHA
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
* DayNight.shadowScale(outdoor)
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
-- arena is a staged shot rather than the world being walked through, and
-- the seams are what make it read as built rather than photographed. Forced
-- through the override so the player's own row is never written to.
local gridWas = VoxelGrid.override
VoxelGrid.override = true
local out = nil
local ok, err = pcall(function()
-- its own canvas slot: this renders at the window's pixel size and the
-- free-roam pass does too, but the two are alive at different moments
-- and a shared slot would reallocate on every battle entry and exit
--
-- AA, if the row asks for it, renders it larger still and folds it back
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
-- the lens was widened by the window's RATIO to the letterbox and the
-- rig solved in the GB's own frame, so a bigger canvas is more samples
-- of the identical shot -- which is why the pins below still measure in
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
-- folded canvas afterwards, stay the chunky GB art they are.
local rw, rh = AntiAlias.expand(pw, ph)
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
return
end
if discs then
-- discs: the two platforms, and nothing else. No terrain, no
-- neighbouring maps, no water, no grass and no flowers -- see the
-- matching skips further down. What is behind them is the sky the
-- clear painted.
V.require("StadiumStage").draw(arena, groundY)
else
Voxel3D.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- and the water over it -- PLAIN, always: the flat animated tiles, never
-- the reflective pass, whatever the WATER row says. The reflection is
-- tuned for the overworld's ladder of cameras; this shot's is PLACED --
-- low, tilted and framed like a picture -- and under it the pass reads
-- wrong: Fresnel opens all the way up, the leaned sky lands on bands the
-- framing never shows, and a lake-sized arena comes out as murk wearing
-- the tile art. The battle is a stage set, and stage water is painted.
-- (No mirror also means the mons need no second draw into one -- they
-- just composite over the water below, like everything else on the set.)
if water then Voxel3D.draw(water, atlasFor(host)) end
for i, nb in ipairs(neighbors) do
if nbWater and nbWater[i] then
Voxel3D.draw(nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
end
end
-- The mons, standing on their tiles. Depth-tested like everything else,
-- so a ledge or a tree between the camera and a Pokemon really is in
-- front of it, and the alpha discard cuts the sprite's own outline out of
-- the card. A small camera-ward pull keeps a card rooted to the ground
-- plane from z-fighting the tile it is standing on.
-- The engine's hit flash is a full-screen white rectangle, which on a
-- white battle field is a flash and over a world is a whiteout of the
-- map, the HUD and the text box alike. It is dropped on the way past
-- (see OverworldBattle) and put back HERE, on the two things it was ever
-- about: the mons themselves go solid white for those frames.
local flashing = textures and textures.flash
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
-- and no voxel wireframe on the pair. Everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that; a
-- mon's card is one quad wearing the battle screen (see
-- BattleBillboard), so it is off the grid and has no seams to draw.
Voxel3D.seams(false)
-- and no glass either: the cards wear the battle screen, not the
-- tileset atlas, so the mask's coordinates mean nothing on them
Voxel3D.glass(false)
for _, card in ipairs(monCards(arena, groundY, textures)) do
-- the sun stored this card snugged (castShadows), so its own shadow
-- lookup must read the same snugged transform -- see ShadowMap.snug
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL, ShadowMap.snug(card.model))
end
Voxel3D.glass(true)
Voxel3D.seams(true)
-- and the STADIUM models, inside the same flash window and with the
-- same camera-ward pull, so a Pokemon standing on its tile still wins
-- the depth test against the tile. They manage the wireframe and the
-- glass mask around their own draws (StadiumRig), which is why this
-- sits outside the pair above rather than inside it.
local okStadium, stadiumErr = pcall(function()
V.require("Stadium").draw(BattleBillboard.PULL)
end)
if not okStadium then V.require("Stadium").report(stadiumErr) end
-- the capture session's Poke Ball, still inside the flash window and
-- with the mons' own camera-ward pull, so a ball crossing in front of
-- a card wins the depth test the way a nearer thing should
local cap = BattleScene.capture
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
-- orbit's -- there is no character here for them to overdraw, but the
-- pull is also what keeps a tuft from z-fighting the floor it stands on
local pull = VoxelScene.pull(math.max(pitch, 0.05))
if not discs then
-- and the WIND blowing through it, exactly as the free-roam pass
-- switches on around its own grass draws (VoxelScene). Without this
-- the uniform sits at the per-frame default beginScene sends -- zero,
-- meaning "no wind" -- and the tall grass a fight is standing in goes
-- dead still for the length of the battle while the same tufts one
-- frame earlier, and one frame after, were moving. A staged fight is
-- shot on the MAP, in that place's own weather and light; a frozen
-- field is the one thing that reads as a photograph of it rather than
-- the place itself.
--
-- No contact point goes with it (grassWind's px/pz are left nil, which
-- sends the far-away sentinel): that push is a WALKER parting the grass
-- they are stepping through, and there is nobody walking here -- the
-- two mons stand still on their own tiles for the whole shot.
Voxel3D.grassWind(true)
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
-- off again before the flowers, which are not grass and have no sway
-- of their own -- the same order the free-roam pass draws them in
Voxel3D.grassWind(false)
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
end
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
if not canvas then return end
local vp = Voxel3D.vp
local pmx, pmy = BattleScene.toGB(vp, arena.player[1], groundY,
arena.player[2], lx, ly, s, pw, ph)
local emx, emy = BattleScene.toGB(vp, arena.enemy[1], groundY,
arena.enemy[2], lx, ly, s, pw, ph)
if not (pmx and emx) then return end
-- How wide one overworld square is on screen where each mon stands, in
-- GB pixels. This is what the pics are scaled to: a mon covers its own
-- square and no more, at whatever the drift has done to the distance.
--
-- Measured along BOTH map axes and answered as the larger, as a full
-- 2D screen distance. One axis alone breaks the moment a camera looks
-- ALONG it: the capture seat stands on the arena's own axis, and on a
-- quarter-turned arena that axis is world X -- the ±X probe points
-- then project to the same pixel and the span reads zero, which
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
local half = BattleScene.CELL / 2
local function cellSpan(wx, wz)
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
lx, ly, s, pw, ph)
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
lx, ly, s, pw, ph)
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
lx, ly, s, pw, ph)
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
lx, ly, s, pw, ph)
if not (x1 and x2 and x3 and x4) then return nil end
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
return math.max(ew, ns)
end
local pSpan = cellSpan(arena.player[1], arena.player[2])
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
if not (pSpan and eSpan) then return end
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
playerSpan = pSpan,
enemySpan = eSpan,
-- the letterbox, so the depth-of-field pass can put its sharp band on
-- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
-- the camera and its combined matrix, for anything that reasons
-- about this shot from outside the render -- the capture mode's
-- throw is solved in these (aim errors along this eye's own right
-- and forward, contact judged through this vp)
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
vp = vp,
-- and the hour's light, for anything drawn over this shot that is NOT
-- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
-- indoors, which is what DayNight.tint answers for a room.
tint = Voxel3D.tint,
}
end)
-- the placed camera is ours for exactly this pass; anything else that
-- renders (the free-roam pipeline, next frame) must find the orbit back
Voxel3D.camera = nil
Voxel3D.SHADOW_ALPHA = sunWas
VoxelGrid.override = gridWas
if not ok then
-- endScene never ran, so the canvas is still bound and the shader still
-- set; put the frame back the way it was found before rethrowing
pcall(love.graphics.setShader)
pcall(love.graphics.setDepthMode)
pcall(love.graphics.setCanvas)
error(err, 0)
end
return out
end
return BattleScene