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 diff --git a/manifest.json b/manifest.json index c668925..299eb32 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.7.1", + "version": "1.7.2", "api": 2, "entry": "main.lua", "profile": "content",