Files
DramaticShapeVoxelMod/lib/OverworldBattle.lua
T
2026-07-28 13:01:38 -04:00

684 lines
30 KiB
Lua

-- Overworld battles: fights that happen on the map you were standing on.
--
-- The engine's battle is a screen: a white field with two pics on it, pushed
-- over a frozen overworld that stops drawing. This turns that white field
-- into the world -- the same terrain the free-roam mode extrudes, shot from
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
-- animation, faint slide and text box is the engine's own, drawn in the
-- engine's own order. What changes is what is behind them, and where the two
-- pics stand.
--
-- The sequence, from the moment something picks a fight:
--
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
-- plays over an empty map and no bystander is left standing in the
-- arena shot
-- 2. the engine's own transition wipes the screen (untouched: it is the
-- right wipe, picked by the right three bits)
-- 3. the battle draws over a live, window-resolution render of the arena,
-- with each mon PINNED to the cell it is standing on, the camera
-- drifting slowly enough to read as parallax, and a depth-of-field pass
-- holding the slab of world the two of them occupy sharp
-- 4. the battle ends, the cast comes back, and the player is exactly
-- where they were standing
--
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
-- real warp would have to survive trainer sight-lines, post-battle
-- dialogue, the blackout path and every script that assumes the player is
-- where it left them -- and it would have to put them back afterwards.
-- Moving the camera buys the whole shot and owes nothing back.
--
-- The feature declines cleanly rather than half-working: no depth support,
-- no open ground on the map, the row switched off, or a mesh still building
-- all end at the same place, which is the battle screen the engine has
-- always drawn.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local BattleArena = V.require("BattleArena")
local BattleCam = V.require("BattleCam")
local BattleScene = V.require("BattleScene")
local BattleDOF = V.require("BattleDOF")
local BattleHud = V.require("BattleHud")
local BattlePics = V.require("BattlePics")
local Voxel3D = V.require("Voxel3D")
local ChunkMesher = V.require("ChunkMesher")
local OverworldBattle = {}
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
-- second, which is how the glyph flip is checked from a shot run. Read
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
-- diagnostic must never be the reason the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end
OverworldBattle.KEY = "battles"
OverworldBattle.LABEL = "3D-BTL"
-- On by default: a mod whose headline is "the world in 3D" should not need
-- the player to go and find the switch before the world shows up in a
-- battle. ON is first, so it is also what an unreadable stored value falls
-- back to.
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
OverworldBattle.LABEL,
{ true, false }, { "ON", "OFF" })
function OverworldBattle.enabled()
return OverworldBattle.setting:get() 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.
--
-- 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 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
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 live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
-- not nest.
local session = nil
local function game()
return require("src.core.Game")
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
-- 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 ok, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
if not (ok and arena) then return false end
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
BattleCam.reset()
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
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
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 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
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.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
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)
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
function OverworldBattle.invalidate()
BattleDOF.invalidate()
BattleHud.invalidate()
BattlePics.invalidate()
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 and 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
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 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 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
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
-- Both sides, or nil when neither has anything to show.
function OverworldBattle.textures(battle)
if not battle then return nil end
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
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
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.
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)
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.
innerPics = BattleState.drawPicsLayer
function BattleState:drawPicsLayer(slide, sx, sy)
if self.dramaticShapeShot then return end
return innerPics(self, slide, sx, sy)
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.
local 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.
local a = OverworldBattle.ANCHOR
local dx = (shot.enemy[1] + shot.player[1]) / 2
- (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + shot.player[2]) / 2
- (a.enemy[2] + a.player[2]) / 2
love.graphics.push()
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
local ok, err = pcall(innerAnim, self, colorized)
love.graphics.pop()
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
-- the colorized pipeline this lands in the grayscale BG canvas, where
-- white IS shade 0 and the zone pass then colours it like every other
-- lightest-shade surface. One rule, both pipelines.
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
local innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
local battle = self
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
innerHUDs(battle, slide)
end)
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
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
local rect = OverworldBattle.HUD_RECT
local live = {}
if enemy then live.enemy = rect.enemy end
if player then live.player = rect.player end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
end
return OverworldBattle