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

BattleScene.lua

lib/BattleScene.lua42.0 KB100644e2ceba2c01
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856
-- 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 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)
    -- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
    -- BattleCam.rig hands back -- the only thing downstream reads it is the
    -- grass and flower pull below. A seat that declines to say stands in
    -- for a near-LEVEL one rather than a top-down one, which is what every
    -- staged seat actually is: the pull grows toward straight down, and a
    -- default that guessed the wrong end of that would spend tens of world
    -- pixels of bias on a camera standing two cells from its subject.
    if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), 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)
  -- The wireframe is whatever the V-GRID row says, exactly as it is out in
  -- the world (see VoxelGrid): the arena is drawn a unit per voxel like
  -- everything else, so the seams follow the one toggle and a player who
  -- turned them off does not get them back for the length of a fight.
  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
    -- and a shiny's arrival sparkle, last of the three so its stars add
    -- over the mon they belong to rather than under it, and still inside
    -- the flash window so a burst during a hit is lit like everything else
    pcall(function()
      V.require("ShinyFx").draw(arena, groundY, 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
  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