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