-- 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 = {}

-- 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
  for _, side in ipairs({ "enemy", "player" }) do
    local tex = textures[side]
    local cell = (side == "player") and arena.player or arena.enemy
    if tex and tex.canvas and cell then
      local mirror = (side == "player") and not tex.trainer
      out[#out + 1] = { tex = tex.canvas,
                        model = monMatrix(tex, cell[1], groundY, cell[2],
                                          mirror) }
    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
  local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
                  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) }
  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)

  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

  -- 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)
  local cam, pitch = BattleCam.rig(arena, groundY)
  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 = 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
    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
      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
      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.
    local half = BattleScene.CELL / 2
    local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
                                arena.player[2], lx, ly, s, pw, ph)
    local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
                                arena.player[2], lx, ly, s, pw, ph)
    local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
                                arena.enemy[2], lx, ly, s, pw, ph)
    local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
                                arena.enemy[2], lx, ly, s, pw, ph)
    if not (pl and pr and el and er) then return end
    out = {
      canvas = canvas,
      player = { pmx, pmy },
      enemy = { emx, emy },
      playerSpan = math.abs(pr - pl),
      enemySpan = math.abs(er - el),
      -- 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,
      -- 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
