mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:20:53 +02:00
1522 lines
71 KiB
Lua
1522 lines
71 KiB
Lua
-- Overworld battles: fights that happen on the map you were standing on.
|
|
--
|
|
-- The engine's battle is a screen: a white field with two pics on it, pushed
|
|
-- over a frozen overworld that stops drawing. This turns that white field
|
|
-- into the world -- the same terrain the free-roam mode extrudes, shot from
|
|
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
|
|
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
|
|
-- animation, faint slide and text box is the engine's own, drawn in the
|
|
-- engine's own order. What changes is what is behind them, and where the two
|
|
-- pics stand.
|
|
--
|
|
-- The sequence, from the moment something picks a fight:
|
|
--
|
|
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
|
|
-- plays over an empty map and no bystander is left standing in the
|
|
-- arena shot
|
|
-- 2. the engine's own transition wipes the screen (untouched: it is the
|
|
-- right wipe, picked by the right three bits)
|
|
-- 3. the battle draws over a live, window-resolution render of the arena,
|
|
-- with each mon PINNED to the cell it is standing on, the camera
|
|
-- drifting slowly enough to read as parallax, and a depth-of-field pass
|
|
-- holding the slab of world the two of them occupy sharp
|
|
-- 4. the battle ends, the cast comes back, and the player is exactly
|
|
-- where they were standing
|
|
--
|
|
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
|
|
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
|
|
-- real warp would have to survive trainer sight-lines, post-battle
|
|
-- dialogue, the blackout path and every script that assumes the player is
|
|
-- where it left them -- and it would have to put them back afterwards.
|
|
-- Moving the camera buys the whole shot and owes nothing back.
|
|
--
|
|
-- The feature declines cleanly rather than half-working: no depth support,
|
|
-- no open ground on the map, the row switched off, or a mesh still building
|
|
-- all end at the same place, which is the battle screen the engine has
|
|
-- always drawn.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local ModSetting = V.require("ModSetting")
|
|
local BattleArena = V.require("BattleArena")
|
|
local BattleCam = V.require("BattleCam")
|
|
local BattleScene = V.require("BattleScene")
|
|
local BattleDOF = V.require("BattleDOF")
|
|
local BattleHud = V.require("BattleHud")
|
|
local BattlePics = V.require("BattlePics")
|
|
local Voxel3D = V.require("Voxel3D")
|
|
local ChunkMesher = V.require("ChunkMesher")
|
|
|
|
local OverworldBattle = {}
|
|
|
|
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
|
|
-- second, which is how the glyph flip is checked from a shot run. Read
|
|
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
|
|
-- diagnostic must never be the reason the mod fails to load.
|
|
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
|
|
if DEBUG == nil or DEBUG == false then DEBUG = nil end
|
|
|
|
OverworldBattle.KEY = "battles"
|
|
OverworldBattle.LABEL = "3D-BTL"
|
|
|
|
-- Five rungs. Two independent choices, laid out as one ladder because they
|
|
-- are one question to the player -- WHAT is standing there, and WHERE:
|
|
--
|
|
-- on the MAP on two DISCS
|
|
-- pics 2D-3D A 2D-3D B
|
|
-- models STADIUM A STADIUM B
|
|
--
|
|
-- 2D-3D A the mode this file was written for: the fight is staged on
|
|
-- the map and the two Pokemon are the GB's OWN PICS, stood up
|
|
-- on their tiles as quads (BattleBillboard).
|
|
-- 2D-3D B those same pics on a pair of DISCS against the sky, with no
|
|
-- map at all (see lib/StadiumStage.lua). The Game Boy's own
|
|
-- framing with the Game Boy's own art, in three dimensions --
|
|
-- and, like every B rung, it works everywhere, including the
|
|
-- caves and shop floors that have nowhere to stage a fight.
|
|
-- STADIUM A the staged fight with the Pokemon Stadium battle models in
|
|
-- place of those quads -- skinned, animated, and playing the
|
|
-- animation the move being used actually calls for (see
|
|
-- lib/Stadium.lua). The world is still the world: the fight
|
|
-- happens on real ground, in the map's own weather and light.
|
|
-- STADIUM B the models on the discs: both halves swapped at once.
|
|
-- OFF the engine's own white battle screen.
|
|
--
|
|
-- A and B is the STAGE and it is the same stage either way -- the discs do
|
|
-- not know what is standing on them and BattleScene draws them off
|
|
-- `arena.discs` alone, which is why the second column cost a value in this
|
|
-- table and nothing else. The four combinations are all reachable rather
|
|
-- than only the diagonal, because a player who cannot use the STADIUM rungs
|
|
-- -- no ROM, or a ROM they would rather not go and find -- should still be
|
|
-- able to have the disc framing, and because the discs are the answer to
|
|
-- "this map has nowhere to fight" whichever art is standing on them.
|
|
--
|
|
-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and
|
|
-- what an unrecognised stored value falls back to, and the stored value for
|
|
-- this row has been `true` since the row existed. Keeping `true` at the head
|
|
-- means every save written before the later rungs existed reads back as the
|
|
-- 2D-3D it was written for, and a mod whose headline is "the world in 3D"
|
|
-- still does not need the player to go and find the switch.
|
|
--
|
|
-- Every other stored value is likewise the one it has always been --
|
|
-- "stadium" from before there was a B, "stadiumB" from before there was a
|
|
-- flat one -- so no save loses the mode it chose.
|
|
--
|
|
-- Both STADIUM rungs are GATED on the models existing: the mod ships no
|
|
-- Pokemon Stadium data, and until the player's own ROM has been found and
|
|
-- built from (StadiumInstall) the row simply has two fewer stops. See
|
|
-- ModSetting.setGate for why they are skipped rather than shown and refused.
|
|
-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are
|
|
-- the game's own art, so it needs nothing the base game did not ship.
|
|
OverworldBattle.FLAT_B = "flatB"
|
|
|
|
OverworldBattle.setting =
|
|
ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
|
|
{ true, "flatB", "stadium", "stadiumB", false },
|
|
{ "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })
|
|
:setGate(function(value)
|
|
if value ~= "stadium" and value ~= "stadiumB" then return true end
|
|
local ok, install = pcall(V.require, "StadiumInstall")
|
|
return ok and install and install.available()
|
|
end)
|
|
|
|
-- Whether the fight stands on the two carried DISCS rather than on the map
|
|
-- -- the B column above, whichever row of it. Asked by stageFor (what to
|
|
-- stage on), wantsFront (whether this map needs an arena at all) and, once
|
|
-- the arena carries the answer as `arena.discs`, by BattleScene and
|
|
-- VoxelScene for what to draw.
|
|
--
|
|
-- Read straight off the row rather than through Stadium, because it is a
|
|
-- question about the STAGE and half the rungs that answer yes have no
|
|
-- Stadium models on them at all.
|
|
function OverworldBattle.discs()
|
|
local value = OverworldBattle.setting:get()
|
|
return (value == OverworldBattle.FLAT_B or value == "stadiumB")
|
|
end
|
|
|
|
-- Whether the VR row is ON -- read lazily, because VR requires modules
|
|
-- that sit above this one. While it is, this mode stops being optional:
|
|
-- the headset's battle seat, the pokedex screen and the effects plane
|
|
-- all assume a fight standing on the world, and a white-field battle
|
|
-- inside a headset is exactly the flat screen VR exists to replace.
|
|
local function vrOn()
|
|
local ok, vr = pcall(V.require, "VR")
|
|
return ok and vr and vr.enabled and vr.enabled() or false
|
|
end
|
|
|
|
function OverworldBattle.enabled()
|
|
if vrOn() then return true end
|
|
return OverworldBattle.setting:get() and true or false
|
|
end
|
|
|
|
-- Whether the STADIUM rung is the one selected -- read through Stadium so
|
|
-- there is one answer to that question and it lives with the mode it
|
|
-- describes. Required lazily: Stadium sits above this file and requires it
|
|
-- back (for the row), which a load-time require would deadlock.
|
|
function OverworldBattle.stadium()
|
|
local ok, stadium = pcall(V.require, "Stadium")
|
|
return (ok and stadium and stadium.enabled()) and true or false
|
|
end
|
|
|
|
-- ------- BACK SPRITES: the player's own mon stays on the menu
|
|
--
|
|
-- The staged shot stands BOTH mons on the map, which is the mode's whole
|
|
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
|
|
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
|
|
-- with its feet on the box. That silhouette is the series' shot.
|
|
--
|
|
-- So BACK SPRITES is offered as a middle setting rather than a compromise
|
|
-- imposed on everyone. With it on the foe is still geometry standing on its
|
|
-- tile at the far end of the arena, and the player's side goes back to being
|
|
-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
|
|
-- feet on row 96.
|
|
-- Nothing else about the shot moves -- the arena, the camera and the drift are
|
|
-- solved exactly as they were, so the foe stands where it always stood and the
|
|
-- player's cell is simply empty ground in the foreground.
|
|
--
|
|
-- OFF by default: what the mode advertises is the pair of them out there.
|
|
OverworldBattle.BACK_KEY = "battleBack"
|
|
OverworldBattle.BACK_LABEL = "BACK SPRITES"
|
|
|
|
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
|
OverworldBattle.BACK_LABEL,
|
|
{ false, true }, { "OFF", "ON" })
|
|
|
|
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
|
|
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
|
|
-- battle screen already draws exactly this. And held OFF under VR: the
|
|
-- headset stands both mons on the world -- a flat back pic pinned to the
|
|
-- 2D frame would keep your own mon off the arena the battle seat looks at.
|
|
function OverworldBattle.backPinned()
|
|
if not OverworldBattle.enabled() then return false end
|
|
if vrOn() then return false end
|
|
return OverworldBattle.backSetting:get() and true or false
|
|
end
|
|
|
|
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
|
|
-- text box, rather than geometry standing out on the map.
|
|
--
|
|
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
|
|
-- that holds the slot until "Go!" -- because that is the only pic this mod
|
|
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
|
|
-- tile whichever mode is on, and with the mode off the player's side is one
|
|
-- too, so both of those keep the open bottom that lets the arena through a
|
|
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
|
|
-- behind its lowest row, so its bottom edge seals.
|
|
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
|
|
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
|
|
-- it that way for the whole render -- during which the pic layer runs, and
|
|
-- picImage asks this. A nil test passes a `false` straight through to the
|
|
-- index below, and the error comes out of sideTexture into the pcall that
|
|
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
|
|
-- simply is not there.
|
|
function OverworldBattle.pinnedPic(battle, img)
|
|
if not (battle and img) then return false end
|
|
if not OverworldBattle.backPinned() then return false end
|
|
if img == battle.playerBackPic then return true end
|
|
local player = battle.player
|
|
return (player and img == player.sprite) and true or false
|
|
end
|
|
|
|
-- ------- both mons face you
|
|
--
|
|
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
|
-- wrong twice over: it is turned away from the camera that is looking at it,
|
|
-- and the back pics are a different, smaller drawing made for a slot the
|
|
-- player never really sees. So the player's side asks for the FRONT pic too,
|
|
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
|
-- exactly this, so no battle code has to be touched to get it.
|
|
--
|
|
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
|
|
-- that mon is drawn in its own slot on the menu, seen from behind, and the
|
|
-- front art would be it turned round to face the player it belongs to.
|
|
--
|
|
-- Answered BEFORE a battle exists, because the battler is built before the
|
|
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
|
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
|
-- available, and the map has an arena -- which is the same question with the
|
|
-- same answer a moment later. Cached per map, because the arena search walks
|
|
-- the whole grid and this runs once per battler.
|
|
local staged = { mapId = nil, ok = false }
|
|
|
|
function OverworldBattle.wantsFront()
|
|
if not OverworldBattle.enabled() then return false end
|
|
if OverworldBattle.backPinned() then return false end
|
|
if not Voxel3D.available() then return false end
|
|
-- required here rather than through the file's own helper: this runs
|
|
-- while a battler is being built, which is before that helper is defined
|
|
local g = require("src.core.Game")
|
|
local ow = g and g.overworld
|
|
if not (ow and ow.map and ow.player) then return false end
|
|
-- a B rung carries its own stage, so the answer is yes on every map and
|
|
-- there is nothing to search or to cache
|
|
if OverworldBattle.discs() then return true end
|
|
if staged.mapId ~= ow.map.id then
|
|
local ok, arena = pcall(BattleArena.find, ow.map,
|
|
ow.player.cellX, ow.player.cellY,
|
|
ow.player.surfing)
|
|
staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
|
|
end
|
|
return staged.ok
|
|
end
|
|
|
|
-- ------- where the engine's own pics stand
|
|
--
|
|
-- The GB draws the player's back pic with its feet on the text box at row 96
|
|
-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
|
|
-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
|
|
-- points are the pics' FEET, they hold for every species at every scale (the
|
|
-- engine's placement helpers pin the bottom edge and the centre), and they
|
|
-- are what BattleCam is solved to put the two arena cells under.
|
|
--
|
|
-- Which makes the pin a subtraction: whatever the drift has done to the
|
|
-- camera this frame, each pic moves by its own cell's projected position
|
|
-- minus its anchor. At the middle of the drift that is zero.
|
|
OverworldBattle.ANCHOR = {
|
|
player = { 26, 96 },
|
|
enemy = { 124, 56 },
|
|
}
|
|
|
|
-- ------- how big a mon is
|
|
--
|
|
-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
|
|
-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
|
|
-- keeps every pixel the artist drew, and the CAMERA is solved so that one
|
|
-- overworld square is that big on screen (see BattleCam). The mon fits its
|
|
-- tile because the tile was sized to the mon, not the other way round.
|
|
OverworldBattle.SLOT_W = { front = 56, back = 32 }
|
|
|
|
-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
|
|
-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
|
|
-- the anchors above.
|
|
OverworldBattle.HUD_RECT = {
|
|
enemy = { 8, 0, 80, 32 },
|
|
player = { 72, 56, 88, 40 },
|
|
}
|
|
|
|
-- ------- the box at the bottom, on the same glass
|
|
--
|
|
-- The HUDs got frosted panels because black glyphs on grass are not readable.
|
|
-- The battle's text box and its menu had the opposite problem and the same
|
|
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
|
|
-- the field's own colour when the field was white and is a sheet of paper laid
|
|
-- over the bottom third of the diorama now that it is not.
|
|
--
|
|
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
|
|
-- frosted glass and laid back down translucent, with the border and the text
|
|
-- drawn over it unchanged, and the same brightness verdict flipping the ink
|
|
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
|
|
-- the engine draws inside the box is still the engine's own, in its own place.
|
|
--
|
|
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
|
|
-- READ-ONLY duplicates of that function's own branches, the same kind of
|
|
-- mirror hudLive is and for the same reason: there is no seam that reports "a
|
|
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
|
|
-- The worst a future engine change can do is frost a rectangle nothing lands
|
|
-- on, or leave a box unfrosted -- never break a battle.
|
|
--
|
|
-- Each rect stops where the next one starts rather than overlapping it: two
|
|
-- panels over the same pixels would frost it twice and leave a visible step
|
|
-- along the seam.
|
|
OverworldBattle.TEXT_RECT = {
|
|
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
|
|
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
|
|
-- above the box above -- its last tile row sits inside that one
|
|
moves = { 0, 64, 88, 32 },
|
|
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
|
|
mimic = { 0, 56, 128, 40 },
|
|
}
|
|
|
|
-- How far apart the two anchors are: the spacing every move animation was
|
|
-- authored against, and so the yardstick the live pair is measured with.
|
|
OverworldBattle.ANCHOR_SPAN = math.sqrt(
|
|
(OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2
|
|
+ (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)
|
|
|
|
-- The effects layer's scale for this shot: how far apart the two mons
|
|
-- actually are on screen, over how far apart the slots they were authored
|
|
-- for were. Clamped hard at both ends -- an effect is pixel art and a wild
|
|
-- factor is worse than a slightly wrong one -- and held at exactly 1 when
|
|
-- the marks coincide, which is a projection about to degenerate rather
|
|
-- than a pair that has genuinely closed up.
|
|
OverworldBattle.ANIM_SCALE_MIN = 0.5
|
|
OverworldBattle.ANIM_SCALE_MAX = 2.0
|
|
|
|
function OverworldBattle.animScale(shot, px, py)
|
|
if not (shot and shot.enemy and px and py) then return 1 end
|
|
local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py
|
|
local span = math.sqrt(dx * dx + dy * dy)
|
|
if not (span > 1) then return 1 end
|
|
local k = span / OverworldBattle.ANCHOR_SPAN
|
|
return math.max(OverworldBattle.ANIM_SCALE_MIN,
|
|
math.min(OverworldBattle.ANIM_SCALE_MAX, k))
|
|
end
|
|
|
|
function OverworldBattle.textRects(battle)
|
|
if not battle or battle.blankForAskName then return {} end
|
|
local r = OverworldBattle.TEXT_RECT
|
|
local out = { box = r.box }
|
|
if battle.phase == "moveSelect" then
|
|
out.moves = r.moves
|
|
elseif battle.phase == "mimicSelect" then
|
|
out.mimic = r.mimic
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- ------- the HUDs, out at the window's own edges
|
|
--
|
|
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
|
|
-- whole of it. That left both HUD blocks huddled together in the middle of the
|
|
-- frame with map showing on either side of them, which reads as a Game Boy
|
|
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
|
|
--
|
|
-- So each block is snapped to its own side: the foe's to the left edge of the
|
|
-- window, the player's to the right. Nothing about either block changes -- same
|
|
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
|
|
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
|
|
-- of the GB screen there is nowhere to go and the snap is a no-op.
|
|
--
|
|
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
|
|
-- canvas and everything outside it is clipped away. So the layer is rendered to
|
|
-- a texture and composited into the WORLD image instead, which is the one
|
|
-- surface in this mode that covers the whole window.
|
|
|
|
-- The rows each block is cut out of, full width. Generous on purpose:
|
|
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
|
|
-- further than the panel does, and the pokeball rows and the safari ball count
|
|
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
|
|
-- outside rows 0-96, and the two bands split that between them.
|
|
OverworldBattle.HUD_BAND = {
|
|
enemy = { 0, 0, 160, 48 },
|
|
player = { 0, 48, 160, 48 },
|
|
}
|
|
|
|
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
|
|
-- glass is cut to, plus the x its band is blitted at.
|
|
--
|
|
-- The foe's panel starts at the window's left edge and the player's ends at the
|
|
-- right one. The vertical is untouched, so both stay on the rows the GB put
|
|
-- them on. A band's own origin sits outside the window by the panel's inset --
|
|
-- the couple of pixels a HUD shake can push past the edge are clipped there,
|
|
-- which is the whole cost of the snap and is invisible.
|
|
function OverworldBattle.snapRects(shot)
|
|
local s = shot.scale
|
|
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
|
|
local ex = -e[1] * s -- foe: panel's left edge to 0
|
|
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
|
|
local rects = {
|
|
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
|
|
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
|
|
}
|
|
return rects, { enemy = ex, player = px }
|
|
end
|
|
|
|
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
|
|
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
|
|
-- where it always was -- but its glass is laid into the world image alongside
|
|
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
|
|
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
|
|
local function toWorld(rect, shot)
|
|
local s = shot.scale
|
|
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
|
|
rect[3] * s, rect[4] * s }
|
|
end
|
|
|
|
-- ------- the live battle
|
|
--
|
|
-- nil when no overworld battle is running. Never more than one: battles do
|
|
-- not nest.
|
|
local session = nil
|
|
|
|
local function isIOS()
|
|
return love.system and love.system.getOS and love.system.getOS() == "iOS"
|
|
end
|
|
|
|
local function game()
|
|
return require("src.core.Game")
|
|
end
|
|
|
|
-- Whether this frame's HUDs went out to the window's edges instead of being
|
|
-- drawn in the GB frame. False whenever the composite could not be made, which
|
|
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
|
|
local function snapped()
|
|
return (session and session.snapped) and true or false
|
|
end
|
|
|
|
-- Put the map's cast back. Both lists are handed back by identity, so
|
|
-- anything that captured one before the battle still sees the same table.
|
|
local function restoreCast()
|
|
if not (session and session.state) then return end
|
|
if session.entities then session.state.entities = session.entities end
|
|
if session.ghosts then session.state.ghosts = session.ghosts end
|
|
session.entities, session.ghosts = nil, nil
|
|
end
|
|
|
|
-- Cull them. The player stays -- they are not an NPC, they are who the
|
|
-- battle belongs to, and Fly/surf animations and the save's own capture read
|
|
-- state.player through this list.
|
|
--
|
|
-- Only the DRAW lists are touched, and only while the overworld is frozen
|
|
-- underneath a battle: StateStack updates the top state alone, so nothing
|
|
-- walks, wanders, triggers or collides against a list that is short for
|
|
-- these frames. The originals go back at battle.ended.
|
|
local function cullCast(state)
|
|
session.entities = state.entities
|
|
session.ghosts = state.ghosts
|
|
state.entities = { state.player }
|
|
state.ghosts = {}
|
|
end
|
|
|
|
-- ------- one right battle layout
|
|
--
|
|
-- Everything this file composes is measured in the GB's own 160x144 frame: the
|
|
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
|
|
-- the frosted panels are cut to, and the full-frame white intercepted to let
|
|
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
|
|
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
|
|
-- -- the mons would stand where no camera was solved for them, and the panels
|
|
-- would land beside the HUDs they are supposed to be under.
|
|
--
|
|
-- So while a fight can be staged on the map there is one right answer, and it
|
|
-- is SET rather than worked around. The engine reads the option live
|
|
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
|
|
-- top state for its surface the same way), so writing it here lands on the
|
|
-- battle being pushed as well as every one after it.
|
|
--
|
|
-- This is the last line rather than the first: the OPTIONS menu takes the row
|
|
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
|
|
-- player is never offered a switch that gets reverted under them. What reaches
|
|
-- here is a value that arrived some other way -- a save written before the mod
|
|
-- was installed, the mod manager's own page, another mod.
|
|
function OverworldBattle.forceOG(g)
|
|
g = g or game()
|
|
local opts = g and g.save and g.save.options
|
|
if not opts or opts.battleLayout ~= "wide" then return false end
|
|
opts.battleLayout = "og"
|
|
if g.writeOptions then pcall(g.writeOptions, g) end
|
|
return true
|
|
end
|
|
|
|
-- Where THIS fight stands, on whichever rung is running: the map's own
|
|
-- ground, or the pair of discs a B rung carries with it.
|
|
--
|
|
-- The one place the two columns actually diverge, and it is worth stating
|
|
-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a
|
|
-- map whose authored entry is a refusal -- and the battle then plays exactly
|
|
-- as the vanilla game does. A B rung cannot fail: its stage is not something
|
|
-- the map has to have room for, so a fight in the tightest cave in Kanto is
|
|
-- staged as readily as one on Route 1.
|
|
function OverworldBattle.stageFor(state)
|
|
if OverworldBattle.discs() and Voxel3D.available() then
|
|
local okStage, arena = pcall(function()
|
|
return V.require("StadiumStage").arena(state.map)
|
|
end)
|
|
if okStage and arena then return arena end
|
|
-- the discs could not be built; fall through to the map, which is a
|
|
-- worse picture but a real one
|
|
end
|
|
local okFind, arena = pcall(BattleArena.find, state.map,
|
|
state.player.cellX, state.player.cellY,
|
|
state.player.surfing)
|
|
return (okFind and arena) or nil
|
|
end
|
|
|
|
-- Stage a battle triggered from `state`, if this mode can. Returns true when
|
|
-- a session started -- which is also the only case where anything visible
|
|
-- changes, so a map with no room for an arena plays exactly the vanilla
|
|
-- battle it always did, cast and all.
|
|
function OverworldBattle.begin(state, battle)
|
|
OverworldBattle.finish()
|
|
if not OverworldBattle.enabled() then return false end
|
|
if not (state and state.map and state.player) then return false end
|
|
if not Voxel3D.available() then return false end
|
|
|
|
local arena = OverworldBattle.stageFor(state)
|
|
if not arena then return false end
|
|
|
|
-- the fight is staged from here on, so the layout it is composed for is not
|
|
-- optional any more (see forceOG)
|
|
OverworldBattle.forceOG()
|
|
|
|
session = { state = state, arena = arena, battle = battle, shot = nil,
|
|
armed = false, token = 0 }
|
|
cullCast(state)
|
|
BattleCam.reset()
|
|
-- and, on the STADIUM rung, the pair of models that will stand on this
|
|
-- arena's two cells. Declines quietly on any other rung.
|
|
pcall(function() V.require("Stadium").begin(arena) end)
|
|
return true
|
|
end
|
|
|
|
-- The fallback entry point: a battle that arrived without going through the
|
|
-- overworld's own pushBattle (a link battle, a script pushing a BattleState
|
|
-- directly). Nothing visible depends on the cull for those -- the wipe has
|
|
-- already been and gone -- but the arena still has to be picked.
|
|
function OverworldBattle.ensure(battle)
|
|
if session then
|
|
-- a battle pushed through the overworld reaches begin() before it is
|
|
-- built far enough to draw; battle.started is where it is finished
|
|
if battle and not session.battle then session.battle = battle end
|
|
return
|
|
end
|
|
local g = game()
|
|
local ow = g and g.overworld
|
|
if ow and ow.map then OverworldBattle.begin(ow, battle) end
|
|
end
|
|
|
|
-- The arena this battle is staged on, or nil. Read by the shot driver so a
|
|
-- screenshot can be labelled with the ground it was taken on.
|
|
function OverworldBattle.arena()
|
|
return session and session.arena or nil
|
|
end
|
|
|
|
function OverworldBattle.finish()
|
|
if not session then return end
|
|
restoreCast()
|
|
session = nil
|
|
Voxel3D.camera = nil
|
|
pcall(function() V.require("Stadium").finish() end)
|
|
end
|
|
|
|
-- ------- per-frame
|
|
--
|
|
-- Driven from the voxel pipeline's update hook, which the engine ticks every
|
|
-- frame regardless of which state is on top -- including the frames the
|
|
-- transition wipe covers, which is what gets the arena's meshes built before
|
|
-- the first battle frame needs them.
|
|
--
|
|
-- The scene is rendered HERE rather than inside the battle's draw, because
|
|
-- update runs with no canvas bound: a 3D pass that binds a depth target and
|
|
-- unbinds to the screen when it is done cannot do that in the middle of
|
|
-- someone else's frame without putting the frame back itself.
|
|
function OverworldBattle.update(dt)
|
|
if not session then return end
|
|
|
|
local g = game()
|
|
local top = g and g.stack and g.stack:top()
|
|
local ow = g and g.overworld
|
|
-- A battle that ended without saying so (a script tearing the state down,
|
|
-- a path that never emits battle.ended) would otherwise leave the cast
|
|
-- culled for good. Armed only once something has actually covered the
|
|
-- overworld, because begin() runs while the overworld is still on top.
|
|
if top ~= nil and top ~= ow then
|
|
session.armed = true
|
|
elseif session.armed then
|
|
OverworldBattle.finish()
|
|
return
|
|
end
|
|
|
|
-- Whether the shot is the player's to steer at all. BACK SPRITES pins
|
|
-- their own mon to the GB's slot on the menu while the foe stands out on
|
|
-- the map, and there is no angle that half-framed, half-solid
|
|
-- composition survives -- so under it the camera holds the shot the rig
|
|
-- was solved for (the slow drift aside, which was always there). Polled
|
|
-- per frame rather than latched at battle start: the row is reachable
|
|
-- from the mod manager's page mid-session.
|
|
BattleCam.steerable = not OverworldBattle.backPinned()
|
|
-- the right stick, read as a rate before the rig is built from it: the
|
|
-- wheel, the keys, the mouse and a drag all arrive as events and have
|
|
-- already landed, but a stick is a HELD position and only a tick can
|
|
-- turn it into travel (CamControl, which owns every one of those inputs)
|
|
pcall(V.require("CamControl").tick, dt)
|
|
BattleCam.update(dt)
|
|
-- the battle only exists once it has been pushed; a session opened at
|
|
-- pushBattle time has it, one opened from battle.started was handed it
|
|
session.battle = session.battle or (top ~= ow and top or nil)
|
|
-- the world pass is hidden behind the battle, so mesh builds get the wide
|
|
-- slice: nothing visible can hitch on them
|
|
ChunkMesher.pump(true)
|
|
|
|
-- The STADIUM models, ahead of the pics, because what they decide is
|
|
-- WHICH pics are needed: a side a model is standing on gets no billboard
|
|
-- texture rendered for it at all (see Stadium.covers). Posed and skinned
|
|
-- here too, once for the frame -- the sun pass, the camera and, in a
|
|
-- headset, both eyes all draw the same skinned meshes.
|
|
pcall(function()
|
|
local host = (session.arena and session.arena.map) or session.state.map
|
|
V.require("Stadium").update(dt, session.battle,
|
|
BattleScene.groundY(host, session.arena))
|
|
end)
|
|
|
|
-- The mons' textures are rendered HERE, with no canvas bound, for the same
|
|
-- reason the scene is: the pics layer binds its own targets, and doing that
|
|
-- inside somebody else's frame means putting the frame back afterwards.
|
|
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
|
|
if not okTex then textures = nil end
|
|
-- stashed for the VR eye pass, which stands these same pics on the map
|
|
-- in ITS view of the world (VoxelScene's eyes path). Stashed HERE
|
|
-- because rendering them binds canvases, which the eye pass -- mid-scene
|
|
-- when it wants them -- must never do; reading a stashed canvas is free.
|
|
session.textures = textures
|
|
-- and the move-animation layer, for the same eyes -- rendered only
|
|
-- while a headset is actually watching, because only the VR world
|
|
-- pass draws it (the flat screen has the animations in-frame already)
|
|
session.animTex = nil
|
|
local okVR, vrOn = pcall(function()
|
|
local vr = V.require("VR")
|
|
return vr.active and vr.active() or false
|
|
end)
|
|
if okVR and vrOn and session.battle then
|
|
local okA, anim = pcall(OverworldBattle.animTexture, session.battle)
|
|
if okA then session.animTex = anim end
|
|
end
|
|
session.token = (session.token or 0) + 1
|
|
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
|
textures, session.token)
|
|
if not ok then
|
|
-- One failure retires the arena for THIS battle and nothing else: the
|
|
-- battle screen carries on as the engine's own, the free-roam pipeline
|
|
-- this runs inside keeps rendering the overworld, and the next battle
|
|
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
|
|
-- guard, which retires a pipeline for the session.
|
|
session.shot = nil
|
|
session.snapped = false
|
|
session.broken = true
|
|
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
|
|
.. "on the plain battle background", tostring(shot))
|
|
return
|
|
end
|
|
session.snapped = false
|
|
if shot and shot.canvas then
|
|
-- the depth of field is measured off the two marks: the slab in focus is
|
|
-- the one the mons are standing in, at whatever the drift has done to
|
|
-- where that lands
|
|
local y1 = shot.ly + shot.player[2] * shot.scale
|
|
local y2 = shot.ly + shot.enemy[2] * shot.scale
|
|
local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
|
|
local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
|
|
focusY, band, range)
|
|
if okDof and blurred then shot.canvas = blurred end
|
|
-- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
|
|
-- so a panel over a blurred far field is frosted from what is actually
|
|
-- behind it
|
|
pcall(BattleHud.build, shot.canvas)
|
|
-- and then the HUDs go ON that backdrop, snapped out to the window's own
|
|
-- edges (snapHUDs). Here rather than in the battle's draw for the same
|
|
-- reason the scene is: it binds a canvas of its own. After the frost, so
|
|
-- the glass is frosted from the world alone and never from the glyphs
|
|
-- about to sit on it.
|
|
local ios = isIOS()
|
|
local okHud, up = false, false
|
|
if not ios then
|
|
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
|
end
|
|
session.snapped = (okHud and up) and true or false
|
|
-- once per battle, not once per frame: a driver that cannot do this cannot
|
|
-- do it sixty times a second either, and the fallback is silent and fine
|
|
if not ios and not okHud and not session.hudWarned then
|
|
session.hudWarned = true
|
|
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
|
|
.. "in the battle frame this battle", tostring(up))
|
|
end
|
|
end
|
|
session.shot = shot
|
|
end
|
|
|
|
-- The finished shot for this frame, or nil when there is none and the battle
|
|
-- should draw the way it always did.
|
|
function OverworldBattle.shot()
|
|
if not session or session.broken then return nil end
|
|
local s = session.shot
|
|
if s and s.canvas then return s end
|
|
return nil
|
|
end
|
|
|
|
-- The staged fight's WORLD-side pieces, for a pass that stands the mons in
|
|
-- its own view of the map rather than in the arena's composed shot -- the
|
|
-- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed
|
|
-- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets
|
|
-- per-eye cards), the live textures table (for the hit-flash flag), and the
|
|
-- token the shadow signature keys on. nil while nothing is staged, the
|
|
-- arena is broken, or the pics have not been rendered yet.
|
|
function OverworldBattle.worldCards()
|
|
if not (session and session.arena and not session.broken) then return nil end
|
|
local tex = session.textures
|
|
if not tex then return nil end
|
|
local host = (session.state and session.state.map) or nil
|
|
if not host then return nil end
|
|
local groundY = BattleScene.groundY(host, session.arena)
|
|
return BattleScene.monCards(session.arena, groundY, tex), tex, session.token
|
|
end
|
|
|
|
-- The live session's BATTLE STATE, once the pushed battle has been met
|
|
-- (session.battle fills in from the stack in update). The VR quad reads
|
|
-- it to tell "the battle screen is on top" from "a menu is over the
|
|
-- battle" -- the UI-only panel is right for the first and wrong for the
|
|
-- second. nil with no session, a broken one, or a battle not yet pushed.
|
|
function OverworldBattle.battle()
|
|
if not (session and not session.broken) then return nil end
|
|
return session.battle
|
|
end
|
|
|
|
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
|
|
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
|
|
-- transparent canvas of its own. This is what stands the effects up in
|
|
-- the VR eyes' world -- see worldAnim below -- the same move the pics
|
|
-- made through sideTexture: let the engine draw what it always draws,
|
|
-- catch it on a canvas, stand the canvas in the scene.
|
|
local animLayer = nil
|
|
-- the engine's own drawAnimLayer, captured by install(). Declared HERE,
|
|
-- above the function that reads it: a local declared further down the
|
|
-- chunk would leave this function reading a global of the same name --
|
|
-- nil forever, and the effects silently absent from the eyes (the bug
|
|
-- this comment is the tombstone of).
|
|
local innerAnim = nil
|
|
|
|
function OverworldBattle.animTexture(battle)
|
|
if not (innerAnim and battle) then return nil end
|
|
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
|
if not animLayer then
|
|
local ok, c = pcall(love.graphics.newCanvas,
|
|
BattleScene.GB_W, BattleScene.GB_H)
|
|
if not (ok and c) then return nil end
|
|
pcall(c.setFilter, c, "nearest", "nearest")
|
|
animLayer = c
|
|
end
|
|
local g = love.graphics
|
|
local prevCanvas = g.getCanvas()
|
|
local ok = pcall(function()
|
|
g.push("all")
|
|
g.origin()
|
|
g.setCanvas(animLayer)
|
|
g.clear(0, 0, 0, 0)
|
|
g.setBlendMode("alpha")
|
|
g.setColor(1, 1, 1, 1)
|
|
innerAnim(battle, false)
|
|
g.pop()
|
|
end)
|
|
if not ok then pcall(g.pop, g) end
|
|
if prevCanvas then pcall(g.setCanvas, g, prevCanvas)
|
|
else pcall(g.setCanvas, g) end
|
|
return ok and animLayer or nil
|
|
end
|
|
|
|
-- The staged fight's effects, for the VR eyes: the animation layer plus
|
|
-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit
|
|
-- authored at a slot lands on the mon standing in for that slot). nil
|
|
-- while nothing is staged or no layer was rendered this frame.
|
|
function OverworldBattle.worldAnim()
|
|
if not (session and session.arena and not session.broken) then return nil end
|
|
local tex = session.animTex
|
|
if not tex then return nil end
|
|
local host = (session.state and session.state.map) or nil
|
|
if not host then return nil end
|
|
local groundY = BattleScene.groundY(host, session.arena)
|
|
local model = BattleScene.fxCard(session.arena, groundY,
|
|
OverworldBattle.ANCHOR)
|
|
if not model then return nil end
|
|
return tex, model
|
|
end
|
|
|
|
-- Where the staged fight STANDS -- the arena and its floor height -- for a
|
|
-- camera that wants to look at it rather than draw it (the VR battle
|
|
-- mount). Answered as soon as the stage exists, textures or not: the
|
|
-- camera should be seated behind the fade before the first pic lands.
|
|
-- nil whenever no fight is staged on the world.
|
|
function OverworldBattle.stage()
|
|
if not (session and session.arena and not session.broken) then return nil end
|
|
local host = (session.state and session.state.map) or nil
|
|
if not host then return nil end
|
|
return session.arena, BattleScene.groundY(host, session.arena)
|
|
end
|
|
|
|
function OverworldBattle.invalidate()
|
|
BattleDOF.invalidate()
|
|
BattleHud.invalidate()
|
|
BattlePics.invalidate()
|
|
-- the STADIUM models hold meshes and textures of this graphics context
|
|
-- like everything else here does
|
|
pcall(function() V.require("Stadium").invalidate() end)
|
|
end
|
|
|
|
-- ------- the battle screen's background
|
|
--
|
|
-- BattleState opens by filling 160x144 white -- that fill IS the battle's
|
|
-- background, and in the colorized pipeline it is also the BG canvas's clear
|
|
-- (nothing else clears it, so skipping it outright would ghost last frame).
|
|
-- So for the length of one draw, that one call is intercepted: on the two
|
|
-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
|
|
-- composites the HUD and the text box over the arena and leaves the empty
|
|
-- field showing it; on the screen it is simply dropped, because the UI canvas
|
|
-- has already been cleared transparent for the world to show through.
|
|
--
|
|
-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
|
|
-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
|
|
-- move-animation flash (which is white at 0.85) all pass through untouched.
|
|
--
|
|
-- This is a shim over love.graphics and it is the one invasive thing here,
|
|
-- so it is scoped as tightly as it can be: installed around a single call,
|
|
-- removed on the way out including on error, and never live outside a battle
|
|
-- frame this mode is drawing.
|
|
local function withoutBackgroundFill(battle, fn)
|
|
local g = love.graphics
|
|
local rectangle = g.rectangle
|
|
g.rectangle = function(mode, x, y, w, h, ...)
|
|
if mode == "fill" and x == 0 and y == 0
|
|
and w == BattleScene.GB_W and h == BattleScene.GB_H then
|
|
local r, gr, b, a = g.getColor()
|
|
if r > 0.99 and gr > 0.99 and b > 0.99 then
|
|
-- Two different full-frame whites, both replaced rather than drawn.
|
|
--
|
|
-- OPAQUE is the battle's background, and on the offscreen canvases it
|
|
-- doubles as their clear, so there it becomes a transparent one.
|
|
--
|
|
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
|
|
-- flash; over a world it whites out the map, the HUD and the text box
|
|
-- together. BattleScene puts it back on the mons alone.
|
|
if a > 0.99 then
|
|
local target = g.getCanvas()
|
|
if target ~= nil
|
|
and (target == battle.bgCanvas or target == battle.waveCanvas) then
|
|
g.clear(0, 0, 0, 0)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
end
|
|
return rectangle(mode, x, y, w, h, ...)
|
|
end
|
|
local ok, err = pcall(fn, battle)
|
|
g.rectangle = rectangle
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- ------- the box, without its paper
|
|
--
|
|
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
|
|
-- opaque slab the frosted panel underneath is there to replace. So for the
|
|
-- length of one drawTextArea the white fills are dropped and everything else
|
|
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
|
|
-- always did, over the glass instead of over paper.
|
|
--
|
|
-- Every fill drawTextArea issues is one of those: the box's own, and the two
|
|
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
|
|
-- the border glyphs that hardware would have overwritten. Both are opaque
|
|
-- white, both are paper, and both go.
|
|
--
|
|
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
|
|
-- installed around a single call, removed on the way out including on error,
|
|
-- never live outside a battle frame this mode is drawing.
|
|
local function withoutBoxFill(battle, fn)
|
|
local g = love.graphics
|
|
local rectangle = g.rectangle
|
|
g.rectangle = function(mode, ...)
|
|
if mode == "fill" then
|
|
local r, gr, b, a = g.getColor()
|
|
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
|
|
end
|
|
return rectangle(mode, ...)
|
|
end
|
|
local ok, err = pcall(fn, battle)
|
|
g.rectangle = rectangle
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- ------- the hour's light, on a pic that is not geometry
|
|
--
|
|
-- Everything standing in the arena goes through the voxel shader, and that
|
|
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
|
|
-- night it goes blue, and the two mons' cards go with it because they are
|
|
-- drawn in the same pass as the ground they stand on.
|
|
--
|
|
-- A back pic pinned to the menu is not in that pass. It is the engine's own
|
|
-- flat blit over the finished shot, so it arrived at noon while the world
|
|
-- behind it was at midnight -- a mon lit by nothing in the frame.
|
|
--
|
|
-- So the tint is applied by hand, to that one draw. Every colour the pics
|
|
-- layer sets is multiplied on its way past, which is the whole of it: the
|
|
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
|
|
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
|
|
-- slide's fade and the blink's own colour all compose with it rather than
|
|
-- being overwritten.
|
|
--
|
|
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
|
|
-- standing under a tree is darker than the tint alone, and this pic has no
|
|
-- position in the scene to be shadowed at. It carries the hour and not the
|
|
-- weather, which is the part the eye reads.
|
|
local function withTint(tint, fn, ...)
|
|
if not tint then return fn(...) end
|
|
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
|
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
|
|
local gfx = love.graphics
|
|
local setColor = gfx.setColor
|
|
gfx.setColor = function(cr, cg, cb, ca, ...)
|
|
if type(cr) == "table" then
|
|
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
|
|
cr[4] }, cg, ...)
|
|
end
|
|
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
|
|
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
|
|
end
|
|
local ok, err = pcall(fn, ...)
|
|
gfx.setColor = setColor
|
|
-- the layer leaves whatever colour it last set, and that one is tinted;
|
|
-- hand the next caller plain white rather than a dimmed one
|
|
setColor(1, 1, 1, 1)
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- ------- the mons, as textures for the 3D pass
|
|
--
|
|
-- The two Pokemon are not composited over the world any more: they are quads
|
|
-- standing in it (see BattleBillboard). What that needs from the battle
|
|
-- screen is a TEXTURE per side -- and the honest way to get one is to let the
|
|
-- engine draw its own pics layer, unchanged, into a canvas.
|
|
--
|
|
-- So the layer is rendered twice, once per side, with the other side
|
|
-- falsified out of existence by nulling exactly the fields its branches
|
|
-- test. Everything the engine does to a pic comes along for free that way:
|
|
-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
|
|
-- faint slide, the damage blink, the squish, every SE displacement. None of
|
|
-- it is reimplemented and none of it can drift.
|
|
--
|
|
-- Two things are forced during that render. The scale, to 1, so the texture
|
|
-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
|
|
-- placement, so the pic lands centred on a known column with its feet on a
|
|
-- known row. That known point is what the quad is then hung from.
|
|
local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline
|
|
local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
|
|
|
|
OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
|
|
|
|
-- Which side is being rendered, or nil. The placement wrappers read it.
|
|
local texturing = nil
|
|
|
|
local texCanvas = {}
|
|
local innerPics = nil -- captured by install()
|
|
local innerHUDs = nil -- likewise, for the snapped HUD layer
|
|
-- (innerAnim, their sibling, is declared up beside animTexture, which
|
|
-- sits earlier in the chunk than this group and must see the local)
|
|
|
|
local function texCanvasFor(side)
|
|
local c = texCanvas[side]
|
|
if c then return c end
|
|
local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
|
|
BattleScene.GB_H, { dpiscale = 1 })
|
|
if not ok then return nil end
|
|
made:setFilter("nearest", "nearest")
|
|
texCanvas[side] = made
|
|
return made
|
|
end
|
|
|
|
-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own
|
|
-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or
|
|
-- not-yet-sent-out mon simply has no billboard this frame.
|
|
local function sideVisible(battle, side)
|
|
if side == "enemy" then
|
|
if battle.showEnemyTrainer and battle.trainerPic then return true end
|
|
return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden
|
|
and not battle.enemySendingOut
|
|
and not battle:fxHidden(battle.enemy)) and true or false
|
|
end
|
|
if battle.showPlayerBack and battle.playerBackPic then return true end
|
|
local hide = battle.safari or battle.demo
|
|
return (battle.player and battle.player.sprite and not hide
|
|
and not battle.sendingOut
|
|
and not battle:fxHidden(battle.player)) and true or false
|
|
end
|
|
|
|
local OFF = {
|
|
enemy = { player = false, showPlayerBack = false },
|
|
player = { enemy = false, showEnemyTrainer = false },
|
|
}
|
|
|
|
-- Render one side's pics layer into its canvas and report where the pic's
|
|
-- feet ended up, in canvas coordinates.
|
|
function OverworldBattle.sideTexture(battle, side)
|
|
if not (innerPics and battle) then return nil end
|
|
-- On the STADIUM rung a side standing a MODEL needs no pic: rendering one
|
|
-- anyway would hang a second, flat copy of the same Pokemon on the same
|
|
-- cell. Asked per side, so a species with no pack -- or a substitute
|
|
-- doll, or the trainer before the send-out -- still comes through here.
|
|
local okS, covered = pcall(function()
|
|
return V.require("Stadium").covers(battle, side)
|
|
end)
|
|
if okS and covered then return nil end
|
|
if not sideVisible(battle, side) then return nil end
|
|
local canvas = texCanvasFor(side)
|
|
if not canvas then return nil end
|
|
|
|
local g = love.graphics
|
|
local prevCanvas = g.getCanvas()
|
|
local prevBlend, prevAlpha = g.getBlendMode()
|
|
-- The pic-window scissors are in the battle screen's fixed coordinates and
|
|
-- would clip a pic that has been moved to the middle of its own canvas.
|
|
-- There is nothing here for them to protect -- no HUD, no text box, just
|
|
-- the one pic -- so they are switched off for the render.
|
|
local setScissor, intersectScissor = g.setScissor, g.intersectScissor
|
|
local getScissor = g.getScissor
|
|
g.setScissor = function() end
|
|
g.intersectScissor = function() end
|
|
g.getScissor = function() return nil end
|
|
|
|
local saved = {}
|
|
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
|
|
texturing = side
|
|
|
|
local ok, err = pcall(function()
|
|
g.setCanvas(canvas)
|
|
g.clear(0, 0, 0, 0)
|
|
g.setBlendMode("alpha")
|
|
g.setColor(1, 1, 1, 1)
|
|
innerPics(battle, 0, 0, 0)
|
|
end)
|
|
|
|
texturing = nil
|
|
for k in pairs(OFF[side]) do battle[k] = saved[k] end
|
|
g.setScissor, g.intersectScissor, g.getScissor =
|
|
setScissor, intersectScissor, getScissor
|
|
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
|
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
|
if not ok then error(err, 0) end
|
|
|
|
local ax, ay = TEX_AX, TEX_AY
|
|
local trainer = false
|
|
-- The intro trainer pic draws itself straight into its own 7x7 slot rather
|
|
-- than through the placement helpers, so it is hung from that slot instead.
|
|
if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then
|
|
ax, ay, trainer = TRAINER_AX, TRAINER_AY, true
|
|
elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then
|
|
trainer = true
|
|
end
|
|
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
|
|
end
|
|
|
|
-- Whether the hit flash is showing this frame.
|
|
--
|
|
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
|
|
-- decision there (a counter plus the frame parity that makes it flicker) and
|
|
-- there is no seam that reports it. Read-only, so the worst a future engine
|
|
-- change can do is flash on a frame the engine would not have.
|
|
function OverworldBattle.flashing(battle)
|
|
local fx = battle and battle.fx
|
|
if not (fx and fx.flash and fx.flash > 0) then return false end
|
|
return (battle.frame or 0) % 4 < 2
|
|
end
|
|
|
|
-- Both sides, or nil when neither has anything to show.
|
|
--
|
|
-- One side under BACK SPRITES: the player's mon is not standing on the map at all
|
|
-- there, it is on the menu, so it has no card to be a texture for -- and
|
|
-- nothing downstream has to know that. No billboard, and no shadow on the
|
|
-- ground under a mon that is not on it.
|
|
function OverworldBattle.textures(battle)
|
|
if not battle then return nil end
|
|
local out = {}
|
|
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
|
local okP, player = true, nil
|
|
if not OverworldBattle.backPinned() then
|
|
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
|
end
|
|
out.enemy = okE and enemy or nil
|
|
out.player = okP and player or nil
|
|
-- On the STADIUM rung both sides can legitimately have no pic -- the pair
|
|
-- of them are models -- and this table must still come back, because it
|
|
-- carries the HIT FLASH, and because the VR eye pass uses its presence to
|
|
-- decide there is a staged fight to draw at all.
|
|
local okStanding, standing = pcall(function()
|
|
return V.require("Stadium").standing()
|
|
end)
|
|
if not (out.enemy or out.player or (okStanding and standing)) then
|
|
return nil
|
|
end
|
|
out.flash = OverworldBattle.flashing(battle)
|
|
return out
|
|
end
|
|
|
|
-- ------- engine seams
|
|
--
|
|
-- Four wraps, each idempotent so a hot reload cannot stack them.
|
|
|
|
function OverworldBattle.install()
|
|
local OverworldState = require("src.world.OverworldController")
|
|
if not OverworldState.dramaticShapeBattleHook then
|
|
local inner = OverworldState.pushBattle
|
|
-- The one place the overworld starts a battle, and it runs BEFORE the
|
|
-- transition is pushed -- which is what lets the cull happen off-screen
|
|
-- and the wipe play over a map with nobody on it.
|
|
function OverworldState:pushBattle(battle)
|
|
pcall(OverworldBattle.begin, self, battle)
|
|
return inner(self, battle)
|
|
end
|
|
OverworldState.dramaticShapeBattleHook = true
|
|
end
|
|
|
|
-- the STADIUM rung's own four wraps, which drive the models' animations
|
|
-- off the fight (see Stadium.install). Idempotent in the same way, and
|
|
-- installed whichever rung the row is on: the wraps do nothing at all
|
|
-- while no stadium session is live.
|
|
pcall(function() V.require("Stadium").install() end)
|
|
|
|
local BattleState = require("src.battle.BattleState")
|
|
if BattleState.dramaticShapeBattleHook then return end
|
|
|
|
-- Integer scales only. The camera is solved to make one overworld square
|
|
-- exactly big enough for a pic at its own integer scale (see BattleCam), so
|
|
-- the fit never has to come out of the pixels -- and a species override or
|
|
-- a battle_sprite_scales entry that asks for 1.7x would undo that and
|
|
-- resample the sprite into mush. Rounded rather than refused, so such a mod
|
|
-- still gets the bigger or smaller mon it asked for, on the pixel grid.
|
|
local innerScale = BattleState.resolveBattleScale
|
|
function BattleState.resolveBattleScale(data, side, path, species)
|
|
local base = innerScale(data, side, path, species)
|
|
-- 1:1 into the billboard texture: the artwork's own pixels, with the
|
|
-- quad's world size doing every bit of the scaling. Anything else would
|
|
-- resample the sprite twice -- once into the texture and again on the way
|
|
-- to the screen -- and a twice-resampled Gen 1 pic is mush.
|
|
if texturing then return 1 end
|
|
if not OverworldBattle.shot() then return base end
|
|
return math.max(1, math.floor((tonumber(base) or 1) + 0.5))
|
|
end
|
|
|
|
-- Keyed-out whites inside a pic used to be filled by the white field
|
|
-- behind it. There is a world back there now, so they are filled here
|
|
-- instead -- see BattlePics, which puts the paper back without touching
|
|
-- the silhouette.
|
|
--
|
|
-- The pinned pic is told that its feet are on the box, which is what lets
|
|
-- the pale-bodied back sprites be filled at all: their bellies leak out
|
|
-- through an opening too wide to read as a drain, and only the box under
|
|
-- them settles that it is not a hole. Passed the pre-bake image, because
|
|
-- that is the one the battle holds a reference to.
|
|
local innerPic = BattleState.picImage
|
|
function BattleState:picImage(img)
|
|
local out = innerPic(self, img)
|
|
if not OverworldBattle.shot() then return out end
|
|
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
|
end
|
|
|
|
-- While a billboard texture is being rendered both pics are put in the same
|
|
-- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad
|
|
-- has one anchor to hang from whichever side and whichever species it is
|
|
-- carrying. Outside that render both helpers answer exactly as they always
|
|
-- did.
|
|
local innerBack = BattleState.backPlacement
|
|
function BattleState.backPlacement(w, h, pad, padL, scale)
|
|
local x, y, s = innerBack(w, h, pad, padL, scale)
|
|
if not texturing then return x, y, s end
|
|
return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s
|
|
end
|
|
|
|
local innerFront = BattleState.frontPlacement
|
|
function BattleState.frontPlacement(ex, ey, w, h, scale)
|
|
local x, y, s = innerFront(ex, ey, w, h, scale)
|
|
if not texturing then return x, y, s end
|
|
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
|
|
end
|
|
|
|
local innerDraw = BattleState.draw
|
|
function BattleState:draw()
|
|
local shot = OverworldBattle.shot()
|
|
-- AskName blanks the field on purpose (the nickname prompt is meant to
|
|
-- sit on nothing); leave that one alone.
|
|
if not shot or self.blankForAskName then
|
|
-- nil, not false: the class default is inherited again, so a battle
|
|
-- that loses its arena mid-fight goes back to white voids
|
|
self.letterboxWhite = nil
|
|
self.dramaticShapeShot = nil
|
|
return innerDraw(self)
|
|
end
|
|
self.dramaticShapeShot = shot
|
|
-- The world reaches the screen through the seam a render pipeline's
|
|
-- finished world image already uses: one window-resolution canvas,
|
|
-- blitted a pixel to a pixel, with the 160x144 UI canvas composited over
|
|
-- it in the classic letterbox afterwards. That is what makes the backdrop
|
|
-- as crisp as the free-roam diorama while the pics and text stay GB art.
|
|
local renderer = game().renderer
|
|
if renderer and renderer.setWorldOverride then
|
|
renderer:setWorldOverride(shot.canvas)
|
|
end
|
|
-- beginFrame clears the UI canvas white for an opaque state; the world is
|
|
-- under it now, so clear it back to nothing and let it through. Safe to
|
|
-- do here: an opaque battle is the lowest state drawn, so nothing has
|
|
-- drawn into this canvas yet.
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
-- the white letterbox exists so the window matches the white battle
|
|
-- canvas; there is a world out to the window edges now
|
|
self.letterboxWhite = false
|
|
OverworldBattle.drawHudPanels(self)
|
|
withoutBackgroundFill(self, innerDraw)
|
|
end
|
|
|
|
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
|
-- before this screen is composited at all, so the flat pics layer has
|
|
-- nothing left to do here. Skipped rather than left to draw underneath, or
|
|
-- every Pokemon would appear twice: once on its tile and once in its slot.
|
|
--
|
|
-- Except under BACK SPRITES, where the player's side never became geometry and this
|
|
-- layer is the only thing that draws it. The engine's own onlySide argument
|
|
-- does the whole job: one call, the player's branches alone, in the slot and
|
|
-- at the scale the GB always put them -- feet on the box, 2x, back view.
|
|
innerPics = BattleState.drawPicsLayer
|
|
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|
local shot = self.dramaticShapeShot
|
|
if not shot then
|
|
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
|
end
|
|
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
|
|
-- under the hour's own light, like everything else in the frame -- see
|
|
-- withTint, and the tint BattleScene hands over with the shot.
|
|
--
|
|
-- Except on the wavy path, where the pic is baked into the GRAYSCALE bg
|
|
-- canvas for the zone pass to colour by region. That pass keys off the
|
|
-- red channel, and a night tint pulls red down -- it would not darken
|
|
-- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a
|
|
-- second and the hour survives it fine.
|
|
local tint = not self.grayPics and shot.tint or nil
|
|
return withTint(tint, innerPics, self, slide, sx, sy, "player",
|
|
skipMenuClip)
|
|
end
|
|
end
|
|
|
|
-- The battle's text box and its menus, over the frosted glass laid down for
|
|
-- them rather than over their own white paper. The INK is Gen 1's own black
|
|
-- and stays that way whatever is behind the glass -- the panel's tint is
|
|
-- what earns it its contrast (see BattleHud).
|
|
local innerText = BattleState.drawTextArea
|
|
function BattleState:drawTextArea()
|
|
if not self.dramaticShapeShot then return innerText(self) end
|
|
if isIOS() then return innerText(self) end
|
|
return withoutBoxFill(self, innerText)
|
|
end
|
|
|
|
-- Move animations are authored against the pics' fixed slots, and a single
|
|
-- animation reaches across both sides, so there is no per-side offset to
|
|
-- give them. They ride the average, which is where the pair's centre went
|
|
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
|
|
-- aimed at instead of drifting off it.
|
|
innerAnim = BattleState.drawAnimLayer
|
|
function BattleState:drawAnimLayer(colorized)
|
|
local shot = self.dramaticShapeShot
|
|
if not shot then return innerAnim(self, colorized) end
|
|
-- Move animations are authored against the pics' old fixed slots, and one
|
|
-- animation reaches across both sides, so there is no per-side offset to
|
|
-- give them. They ride to where the PAIR went: the midpoint of the two
|
|
-- mons' projected positions, less the midpoint of the slots they used to
|
|
-- sit in. A hit still lands on the mon it is aimed at.
|
|
--
|
|
-- And they ride the pair's SEPARATION as well, because the mons
|
|
-- themselves do. Both are geometry standing on the map, so the camera
|
|
-- sizes them: zoom in and they grow, swing round to side-on and the two
|
|
-- marks close up as the axis foreshortens. A layer that only slid would
|
|
-- have held the authored 106-pixel spacing through all of it -- a beam
|
|
-- fired between two mons that are no longer that far apart, ending in
|
|
-- the air beside the one it was aimed at. Scaling about the same
|
|
-- midpoint keeps every authored offset the same fraction of the gap it
|
|
-- was authored as.
|
|
local a = OverworldBattle.ANCHOR
|
|
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
|
-- contributes no movement at all and the pair's centre has gone half as
|
|
-- far as the foe's mark did.
|
|
local px, py = shot.player[1], shot.player[2]
|
|
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
|
local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2
|
|
local ax = (a.enemy[1] + a.player[1]) / 2
|
|
local ay = (a.enemy[2] + a.player[2]) / 2
|
|
love.graphics.push()
|
|
love.graphics.translate(cx - ax, cy - ay)
|
|
-- Clamped, and skipped outright if the marks ever coincide: a
|
|
-- degenerate projection must leave the effects the size they were
|
|
-- rather than collapse them to nothing or blow them across the screen.
|
|
local k = OverworldBattle.animScale(shot, px, py)
|
|
if k ~= 1 then
|
|
love.graphics.translate(ax, ay)
|
|
love.graphics.scale(k, k)
|
|
love.graphics.translate(-ax, -ay)
|
|
end
|
|
local ok, err = pcall(innerAnim, self, colorized)
|
|
love.graphics.pop()
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- The engine's flash has a SECOND half, and it is the one that reaches the
|
|
-- menu. Beside the white rectangle (dropped above) the flash moves are
|
|
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
|
|
-- colorized pipeline applies in drawZonePass to the WHOLE background
|
|
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
|
|
-- meant for the two mons washed the menu out with them.
|
|
--
|
|
-- The fade is left switched on for the pics, which read it through
|
|
-- picImage, and switched off for the zone pass alone. So the mons flash
|
|
-- and the furniture around them does not.
|
|
--
|
|
-- The zone pass has a SECOND thing it paints, and this is the one that
|
|
-- reads as the menu box flashing. A screen shake makes it fill every zone
|
|
-- with the zone's own color 0 before it draws the offset copy -- the
|
|
-- hardware showing empty BG in the strip the shake vacated. On a white
|
|
-- battle field that fill is invisible; over a world it is an opaque white
|
|
-- sheet across the whole frame, and since a shake program alternates
|
|
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
|
|
-- switches on and off a few times a second. It is dropped: the background
|
|
-- here is the map, so what the shake vacates should show the map.
|
|
local innerZone = BattleState.drawZonePass
|
|
function BattleState:drawZonePass(src, sx, sy)
|
|
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
|
|
-- shadow the method on the instance for this call only; putting the
|
|
-- field back to whatever it was (normally nil) lets the class method be
|
|
-- found again
|
|
local had = rawget(self, "activeBgp")
|
|
self.activeBgp = function() return nil end
|
|
local g = love.graphics
|
|
local rectangle = g.rectangle
|
|
g.rectangle = function(mode, ...)
|
|
-- the pass draws no other rectangle; the shake still shifts the copy
|
|
if mode == "fill" then return end
|
|
return rectangle(mode, ...)
|
|
end
|
|
local ok, err = pcall(innerZone, self, src, sx, sy)
|
|
g.rectangle = rectangle
|
|
self.activeBgp = had
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
innerHUDs = BattleState.drawHUDs
|
|
function BattleState:drawHUDs(slide)
|
|
-- Normally the HUDs have already been drawn this frame, snapped out to the
|
|
-- window's edges and composited into the world image (snapHUDs). Drawing
|
|
-- them here as well would show each block twice, once in each place.
|
|
if self.dramaticShapeShot and snapped() then return end
|
|
return innerHUDs(self, slide)
|
|
end
|
|
|
|
BattleState.dramaticShapeBattleHook = true
|
|
end
|
|
|
|
-- Whether each HUD block is on screen this frame.
|
|
--
|
|
-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam
|
|
-- that reports "the enemy HUD is up". A panel under a HUD that is not there
|
|
-- would be a frosted slab floating in the arena, so it is worth mirroring;
|
|
-- the worst a future engine change can do is show an empty one for a frame,
|
|
-- never break a battle.
|
|
function OverworldBattle.hudLive(battle, slide)
|
|
local enemy = battle.enemy and not battle.showEnemyTrainer
|
|
and not battle.enemySendingOut
|
|
and not battle:growInScale(battle.enemy) and slide == 0
|
|
and not battle.enemy.fainted
|
|
local player = battle.player and not (battle.safari or battle.demo)
|
|
and not battle.showPlayerBack and slide == 0
|
|
return enemy and true or false, player and true or false
|
|
end
|
|
|
|
-- ------- the snapped composite
|
|
--
|
|
-- The engine's own HUD layer, rendered into a texture.
|
|
--
|
|
-- One thing is falsified for the render, and it is falsified because this layer
|
|
-- never reaches the battle's zone pass -- it is composited into the world image,
|
|
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
|
|
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
|
|
-- answered false, it tints its own greens and reds instead, exactly as it does
|
|
-- on the flat path.
|
|
--
|
|
-- Shadowed on the instance for this call only, the way drawZonePass shadows
|
|
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
|
|
-- class method be found again.
|
|
function OverworldBattle.hudTexture(battle, slide)
|
|
if not (innerHUDs and battle) then return nil end
|
|
local had = rawget(battle, "colorMode")
|
|
battle.colorMode = function() return false end
|
|
local ok, layer = pcall(BattleHud.layerTexture,
|
|
BattleScene.GB_W, BattleScene.GB_H,
|
|
function() innerHUDs(battle, slide) end)
|
|
battle.colorMode = had
|
|
return ok and layer or nil
|
|
end
|
|
|
|
-- Draw both HUD blocks into the world image at the window's edges, each on its
|
|
-- own frosted panel. Returns true when the frame's HUDs are up there and the
|
|
-- in-frame draw must be skipped; false leaves the battle screen's own HUD
|
|
-- exactly as it was before any of this existed.
|
|
--
|
|
-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
|
|
-- carries more than the HUD: the pokeball rows of the intro and of an enemy
|
|
-- faint, and the safari ball count, all draw in these rows and belong at the
|
|
-- same edge as the block they share it with. The panels are the ones that
|
|
-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
|
|
function OverworldBattle.snapHUDs(battle, shot)
|
|
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
|
|
return false
|
|
end
|
|
-- With a headset live the HUDs stay IN the GB frame -- the classic
|
|
-- slots, on the glass drawHudPanels lays for the unsnapped path. Both
|
|
-- of VR's battle screens (the floating panel and the pokedex's) crop
|
|
-- to the letterbox, and a block snapped out to the window's edge would
|
|
-- be cropped away with the window around it.
|
|
local okV, vr = pcall(V.require, "VR")
|
|
if okV and vr and vr.active and vr.active() then return false end
|
|
local slide = (battle.introSlide or 0) * 4
|
|
local rects, bandX = OverworldBattle.snapRects(shot)
|
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
|
local live = {}
|
|
if enemy then live.enemy = rects.enemy end
|
|
if player then live.player = rects.player end
|
|
-- and the text box's own glass, on the same pass. It stays in the middle of
|
|
-- the frame where the engine draws it -- only the HUDs were snapped out --
|
|
-- so its GB rect is mapped into the letterbox rather than to an edge.
|
|
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
|
live[key] = toWorld(rect, shot)
|
|
end
|
|
local layer = OverworldBattle.hudTexture(battle, slide)
|
|
if not layer then return false end
|
|
|
|
local g = love.graphics
|
|
local prevCanvas = g.getCanvas()
|
|
local prevBlend, prevAlpha = g.getBlendMode()
|
|
local ok, err = pcall(function()
|
|
g.setCanvas(shot.canvas)
|
|
g.setBlendMode("alpha")
|
|
for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end
|
|
g.setColor(1, 1, 1, 1)
|
|
for side, band in pairs(OverworldBattle.HUD_BAND) do
|
|
local quad = g.newQuad(band[1], band[2], band[3], band[4],
|
|
BattleScene.GB_W, BattleScene.GB_H)
|
|
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
|
|
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
|
|
end
|
|
end)
|
|
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
|
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
|
g.setColor(1, 1, 1, 1)
|
|
if not ok then error(err, 0) end
|
|
return true
|
|
end
|
|
|
|
-- Lay the frosted glass down under whichever HUD and box are about to draw,
|
|
-- and record which way the glyphs have to flip.
|
|
--
|
|
-- The panels are the fallback path only: normally the HUDs are snapped out to
|
|
-- the window's edges and their glass, and the box's, went into the world image
|
|
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
|
|
-- drawn here, in the GB frame, whichever path laid the glass under it.
|
|
function OverworldBattle.drawHudPanels(battle)
|
|
local shot = battle.dramaticShapeShot
|
|
if not shot then return end
|
|
if isIOS() then
|
|
local slide = (battle.introSlide or 0) * 4
|
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
|
local rect = OverworldBattle.HUD_RECT
|
|
love.graphics.setColor(1, 1, 1, 0.84)
|
|
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
|
|
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
return
|
|
end
|
|
if snapped() then
|
|
return
|
|
end
|
|
local slide = (battle.introSlide or 0) * 4
|
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
|
local rect = OverworldBattle.HUD_RECT
|
|
local live = {}
|
|
if enemy then live.enemy = rect.enemy end
|
|
if player then live.player = rect.player end
|
|
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
|
|
if not next(live) then return end
|
|
for _, r in pairs(live) do BattleHud.panel(r, shot) end
|
|
end
|
|
|
|
return OverworldBattle
|