mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:10:51 +02:00
431 lines
20 KiB
Lua
431 lines
20 KiB
Lua
-- Overworld battles: one frame of the arena, as geometry.
|
|
--
|
|
-- The same world the free-roam mode draws, from a placed camera instead of
|
|
-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
|
|
-- backdrop reaches the screen through Renderer's worldOverride, the seam a
|
|
-- render pipeline's finished world image already composites through, which
|
|
-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen
|
|
-- then blits over it in the classic letterbox. So the world is as crisp as
|
|
-- the free-roam diorama and the pics, HUDs and text box stay exactly the
|
|
-- chunky GB art they are.
|
|
--
|
|
-- Rendering the whole window rather than just the letterbox means the
|
|
-- framing has to be split in two. The RIG frames the GB's 160x144 (see
|
|
-- BattleCam, which is solved against coordinates in that frame); this
|
|
-- module widens the lens by exactly the ratio the window bears to the
|
|
-- letterbox, so the letterbox sub-rectangle of what gets rendered is
|
|
-- bit-for-bit the framing the rig asked for, and everything outside it is
|
|
-- extra picture. That is what lets the two mons be PINNED: their cells
|
|
-- project to the same GB coordinates at any window size or zoom.
|
|
--
|
|
-- Characters are deliberately absent. The overworld cast is culled for the
|
|
-- length of the battle (see OverworldBattle), so this pass has terrain,
|
|
-- grass and flowers and nothing that walks -- the arena is empty, which is
|
|
-- what makes it an arena.
|
|
--
|
|
-- Everything expensive is shared with the free-roam mode rather than
|
|
-- duplicated: the same chunk meshes out of ChunkMesher, the same palette
|
|
-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a
|
|
-- map already meshed for walking around costs the frame it draws and
|
|
-- nothing else.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local Mat4 = V.require("Mat4")
|
|
local Voxel3D = V.require("Voxel3D")
|
|
local ShadowMap = V.require("ShadowMap")
|
|
local ChunkMesher = V.require("ChunkMesher")
|
|
local TerrainAtlas = V.require("TerrainAtlas")
|
|
local VoxelScene = V.require("VoxelScene")
|
|
local BattleCam = V.require("BattleCam")
|
|
local BattleBillboard = V.require("BattleBillboard")
|
|
local VoxelGrid = V.require("VoxelGrid")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
|
|
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)
|
|
local terrain = ChunkMesher.request(host, false, nil, true)
|
|
or ChunkMesher.peek(host, true)
|
|
return terrain, {}
|
|
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 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) }
|
|
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)
|
|
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
|
|
|
|
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
|
|
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil)
|
|
for _, nb in ipairs(neighbors) do
|
|
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
|
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.
|
|
for _, card in ipairs(cards or {}) do
|
|
ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model)
|
|
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)
|
|
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.
|
|
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
|
|
|
|
-- 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 {}
|
|
|
|
-- 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 = prefetchArena(state, host)
|
|
if not terrain then return nil 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
|
|
local vh = BattleCam.rigFor(arena).frameH * 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)
|
|
|
|
-- 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)
|
|
|
|
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
|
|
local sunWas = Voxel3D.SHADOW_ALPHA
|
|
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
|
|
-- 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
|
|
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
|
|
return
|
|
end
|
|
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
|
|
-- 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.
|
|
for _, card in ipairs(monCards(arena, groundY, textures)) do
|
|
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
|
BattleBillboard.PULL)
|
|
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))
|
|
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)
|
|
for _, nb in ipairs(neighbors) do
|
|
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
|
Mat4.translate(nb.ox, 0, nb.oy), fpull)
|
|
end
|
|
local canvas = Voxel3D.endScene()
|
|
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,
|
|
}
|
|
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
|