Fix iOS battle HUD compositing
Alonso Avitia · 2026-08-06 ·
parent e14cf3de90
Fix iOS battle HUD compositing
Changed files
lib/OverworldBattle.lua | 3048 ++++++++++++++++++++++++++++++++++---------------------------------
1 file changed, 1527 insertions(+), 1521 deletions(-)
Diff
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index 7e29167..7d167df 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -1,1521 +1,1527 @@
--- 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
+-- 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 okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
+ 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 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
+ 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 not isIOS() then
+ if enemy then live.enemy = rects.enemy end
+ if player then live.player = rects.player end
+ end
+ -- The text box's frost panel normally goes into this same world-canvas pass.
+ -- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating
+ -- the large ghost rectangle behind the Pokemon. Keep the box border/text but
+ -- skip only this frosted backing on iOS.
+ if not isIOS() then
+ for key, rect in pairs(OverworldBattle.textRects(battle)) do
+ live[key] = toWorld(rect, shot)
+ end
+ 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)
+ local x = bandX[side] + band[1] * shot.scale
+ local targetY = shot.ly + band[2] * shot.scale
+
+ if isIOS() then
+ -- Keep the player's HUD exactly where it currently appears on the
+ -- right. Only the enemy band needs its mirrored destination corrected.
+ local y = targetY
+ if side == "enemy" then
+ y = shot.ph - targetY - band[4] * shot.scale
+ end
+
+ -- iOS presents this Canvas-to-Canvas HUD texture upside down.
+ g.draw(layer, quad, x, y, 0,
+ shot.scale, -shot.scale, 0, band[4])
+ else
+ g.draw(layer, quad, x, targetY, 0,
+ shot.scale, shot.scale)
+ end
+ 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 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