lets go
DramaticShape · 2026-08-08 ·
parent 063ce6e328
lets go
Changed files
CHANGELOG.md | 50 +
lib/BattleScene.lua | 121 ++-
lib/CatchThrow.lua | 1605 +++++++++++++++++++++++++++++++
lib/LetsGo.lua | 404 ++++++++
lib/Mat4.lua | 8 +
lib/OverworldBattle.lua | 3109 +++++++++++++++++++++++++++++++------------------------------
lib/Pokeball.lua | 594 ++++++++++++
lib/Stadium.lua | 45 +
lib/StadiumMon.lua | 49 +
lib/StadiumRig.lua | 30 +
main.lua | 44 +
tests/dramatic_shape_test.lua | 106 ++-
tests/letsgo_shots.lua | 309 ++++++
tests/letsgo_trainer_exp.lua | 115 +++
14 files changed, 5039 insertions(+), 1550 deletions(-)
Diff
Diff truncated at 200.0 KB — fetch the full change with git show f0d5ca570a after cloning.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18972bb..cb1a20b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Changelog
+## 1.8.0
+
+### Added
+
+- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new
+ three-rung row. CATCH ONLY changes nothing about the game except the
+ throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the
+ BALL row of the safari menu) opens capture mode instead of the automatic
+ toss. FULL makes wild encounters the real Let's Go article: the
+ encounter IS the catch -- it opens in throwing mode and stays there,
+ the foe never takes a turn, your own Pokemon is never sent out or
+ shown (no back pic, no model, no HUD), and B runs, which from a catch
+ encounter always works. Poke/Great/Ultra Balls are half price at every
+ mart, and EXPERIENCE works the way that game's does: every healthy
+ party member gains from every catch AND every trainer knockout, each
+ one measured against its OWN level through the Gen VII scaled formula
+ -- which is why Let's Go ships no EXP.ALL, and why a level 5 party
+ member takes several times what a level 45 one does from the very same
+ fight. A catch adds the throw stack on top: grade, first ball of the
+ encounter, new species, and a persistent catch combo. CATCH ONLY
+ leaves experience exactly as the original game had it.
+
+ **The throw.** The camera locks HEAD ON with the wild Pokemon -- its
+ own seat on the arena's axis, no drift, no steer -- and a real 3D Poke
+ Ball (modelled and animated for this: hinged lid, capture beam,
+ squash-click, decaying wobble, caught stars, breakout burst; GREAT
+ blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the
+ bottom of the frame. The ball rides UNDER the finger -- mouse, touch,
+ or the right stick -- and releasing throws it with the swipe's own
+ velocity: forward from how hard, height from its rise, side from its
+ slant, gravity and collision deciding the rest, with a bearing-and-
+ range assist trimming honest errors. Circling the ball WINDS it -- the
+ spin visibly builds with the gesture to a cap and bleeds off when the
+ hand pauses -- and only a ball at the cap flies with the late-biting
+ curve. Contact is against the creature's own GEOMETRY: a pic foe is
+ its sprite's opaque pixels (a ball through the gap under a wing flies
+ on), a STADIUM foe its model's measured height, girth and hover. The
+ timing ring pulses on the creature, coloured by the Gen 1 odds, and is
+ judged AT the moment of contact: inside earns NICE / GREAT /
+ EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
+ shakes the roll answers are the rocks the ball plays on the ground.
+
+ **What it stands on.** The outcome is exactly a Gen 1 ball throw: same
+ catch math (status, HP and ball factors intact), same outcome texts,
+ same caught flow -- dex page, nickname, box overflow -- and a missed
+ ball is a spent ball. Outside FULL, a failed throw still costs the
+ turn it always did. Needs the staged 3D battle standing (3D-BTL on, a
+ depth-capable driver, no headset); anywhere it cannot stand, balls
+ quietly take the engine's classic toss.
+
## 1.7.1
### Added
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index dbaf442..ed4b6d4 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
local BattleScene = {}
+-- ------- LET'S GO capture mode's stake in this scene
+--
+-- One table while a capture session runs, nil otherwise (see
+-- lib/CatchThrow.lua, which owns it):
+--
+-- hidePlayer the player's side stays out of the shot entirely -- no
+-- card here, no model (Stadium reads this same table), no
+-- pinned back pic (OverworldBattle reads it too)
+-- shrink the foe's scale while the ball drinks it in, applied
+-- about its chest so it collapses toward the beam
+-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
+-- depth buffer, same flash window, same camera
+-- cast(sm) the same ball into the sun's pass
+-- sig() a term for the cached shadow signature, so a ball in
+-- flight re-casts and a resting scene does not
+-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
+-- BattleState:draw wrap in the GB frame
+--
+-- It lives HERE, not on OverworldBattle, because every consumer below
+-- already requires BattleScene and the one file that writes it requires
+-- both -- this is the spot with no require cycle.
+BattleScene.capture = nil
+
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against.
BattleScene.GB_W = 160
@@ -195,14 +218,29 @@ end
local function monCards(arena, groundY, textures)
local out = {}
if not textures then return out end
+ local cap = BattleScene.capture
for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy
+ -- capture mode: the player's side is out of the shot (the seat looks
+ -- over an empty shoulder), and OverworldBattle.textures already
+ -- skipped rendering it -- this is the belt to that suspender
+ if side == "player" and cap and cap.hidePlayer then tex = nil end
if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer
- out[#out + 1] = { tex = tex.canvas,
- model = monMatrix(tex, cell[1], groundY, cell[2],
- mirror) }
+ local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
+ -- the foe drinking into the ball: scaled about its own chest, in
+ -- world space so the composed card matrix needs no decomposition
+ if side == "enemy" and cap and cap.shrink then
+ local k = cap.shrink
+ local ax, ay, az = cell[1], groundY + 8, cell[2]
+ model = Mat4.mul(
+ Mat4.mul(Mat4.translate(ax, ay, az),
+ Mat4.mul(Mat4.scale(k, k, k),
+ Mat4.translate(-ax, -ay, -az))),
+ model)
+ end
+ out[#out + 1] = { tex = tex.canvas, model = model }
end
end
return out
@@ -313,6 +351,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
-- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 128) }
+ -- a capture session's ball moves through the sun's world too; its term
+ -- is quantised inside sig() so the cache re-renders on real movement
+ -- and not on every frame the ball rests
+ local cap = BattleScene.capture
+ if cap and cap.sig then
+ local okSig, sig = pcall(cap.sig)
+ parts[#parts + 1] = okSig and sig or "cap"
+ end
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
@@ -377,6 +423,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
-- the water. Un-snugged for the same reason: snug is a bias for a card
-- rooted to the ground plane, and a model has thickness of its own.
pcall(function() V.require("Stadium").cast(ShadowMap) end)
+ -- the capture session's ball, by the same reasoning: real geometry, its
+ -- shadow is half of what sells the arc
+ local cap = BattleScene.capture
+ if cap and cap.cast then pcall(cap.cast, ShadowMap) end
ShadowMap.finish(sig)
end
@@ -520,7 +570,18 @@ function BattleScene.render(state, arena, textures, token)
end
local groundY = BattleScene.groundY(host, arena)
- local cam, pitch = BattleCam.rig(arena, groundY)
+ -- A capture session brings a camera of its own: the head-on seat, on
+ -- the arena's axis looking straight at the foe, in place of the solved
+ -- over-the-shoulder shot. Everything downstream -- the letterbox fov,
+ -- the pins, the sun, the cards yawing to the eye -- is generic over
+ -- whichever camera this is.
+ local cam, pitch, capFrameH
+ local cap = BattleScene.capture
+ if cap and cap.rig then
+ local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
+ if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
+ end
+ if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
@@ -529,7 +590,8 @@ function BattleScene.render(state, arena, textures, token)
-- the player's zoom is part of this: the sun's box is fitted to what the
-- frame holds, so a shot pulled wide has to light the ground it just
-- brought into view rather than the ground the rig alone would have
- local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
+ local vh = (capFrameH or BattleCam.frameH(arena)) * ph
+ / (BattleScene.GB_H * s)
local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live
@@ -662,6 +724,11 @@ function BattleScene.render(state, arena, textures, token)
V.require("Stadium").draw(BattleBillboard.PULL)
end)
if not okStadium then V.require("Stadium").report(stadiumErr) end
+ -- the capture session's Poke Ball, still inside the flash window and
+ -- with the mons' own camera-ward pull, so a ball crossing in front of
+ -- a card wins the depth test the way a nearer thing should
+ local cap = BattleScene.capture
+ if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
@@ -695,25 +762,47 @@ function BattleScene.render(state, arena, textures, token)
-- How wide one overworld square is on screen where each mon stands, in
-- GB pixels. This is what the pics are scaled to: a mon covers its own
-- square and no more, at whatever the drift has done to the distance.
+ --
+ -- Measured along BOTH map axes and answered as the larger, as a full
+ -- 2D screen distance. One axis alone breaks the moment a camera looks
+ -- ALONG it: the capture seat stands on the arena's own axis, and on a
+ -- quarter-turned arena that axis is world X -- the ±X probe points
+ -- then project to the same pixel and the span reads zero, which
+ -- collapsed the ring and blew up the throw's world-per-pixel mapping.
local half = BattleScene.CELL / 2
- local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
- arena.player[2], lx, ly, s, pw, ph)
- local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
- arena.player[2], lx, ly, s, pw, ph)
- local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
- arena.enemy[2], lx, ly, s, pw, ph)
- local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
- arena.enemy[2], lx, ly, s, pw, ph)
- if not (pl and pr and el and er) then return end
+ local function cellSpan(wx, wz)
+ local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
+ lx, ly, s, pw, ph)
+ local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
+ lx, ly, s, pw, ph)
+ local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
+ lx, ly, s, pw, ph)
+ local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
+ lx, ly, s, pw, ph)
+ if not (x1 and x2 and x3 and x4) then return nil end
+ local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
+ local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
+ return math.max(ew, ns)
+ end
+ local pSpan = cellSpan(arena.player[1], arena.player[2])
+ local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
+ if not (pSpan and eSpan) then return end
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
- playerSpan = math.abs(pr - pl),
- enemySpan = math.abs(er - el),
+ playerSpan = pSpan,
+ enemySpan = eSpan,
-- the letterbox, so the depth-of-field pass can put its sharp band on
-- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
+ -- the camera and its combined matrix, for anything that reasons
+ -- about this shot from outside the render -- the capture mode's
+ -- throw is solved in these (aim errors along this eye's own right
+ -- and forward, contact judged through this vp)
+ eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
+ focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
+ vp = vp,
-- and the hour's light, for anything drawn over this shot that is NOT
-- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
new file mode 100644
index 0000000..b573115
--- /dev/null
+++ b/lib/CatchThrow.lua
@@ -0,0 +1,1605 @@
+-- LET'S GO capture mode: the throw itself.
+--
+-- The player sees the wild Pokemon from the staged battle's own
+-- over-the-shoulder seat -- held perfectly still for the duration, with
+-- their own Pokemon nowhere in the shot -- a Poke Ball hanging in the
+-- foreground, and a timing ring pulsing on the foe. A flick of the mouse,
+-- a finger, or the right stick throws the ball along a solved arc; spin
+-- the ball first and it flies with a visible curve. Where the ball crosses
+-- the foe's plane is judged in SCREEN space against the ring -- the player
+-- aimed at pixels, so pixels are what they are graded on -- and a throw
+-- inside the shrinking ring earns NICE / GREAT / EXCELLENT, which
+-- multiplies the Gen 1 catch roll the engine then makes. The ball opens,
+-- drinks the Pokemon in, drops, rocks once per engine shake, and either
+-- clicks with stars or bursts the Pokemon back out.
+--
+-- ------- what this file owns, and what it borrows
+--
+-- It owns the SESSION: input sampling, the flick estimate, the arc, the
+-- ring, the grading, and the choreography of the Pokeball prop. Everything
+-- consequential is borrowed from the engine so the outcome is exactly a
+-- Gen 1 ball throw: the roll is battle:catchAttempt (status, HP and ball
+-- factors intact, our throw grade folded into the rate), the outcome texts
+-- and the enemy's revenge turn are the same queue throwBall builds, and a
+-- catch lands in storeCaughtMon -- dex page, nickname, box overflow and
+-- the pokemon.caught event all included. While the session runs the battle
+-- is FROZEN by parking battle.phase on a value the engine's update has no
+-- branch for; the resolution puts "messages" back and the engine finishes
+-- the fight believing it threw the ball itself.
+--
+-- ------- the input model (design: flick-to-throw)
+--
+-- All pointer samples are kept in GB-frame pixels (160x144), the same
+-- space the ring is drawn in. The flick velocity is a least-squares slope
+-- over the last 110 ms -- never last-minus-previous, which one frame of
+-- jitter destroys. Strength comes from speed, direction from the fit, and
+-- the landing point is "the spot the flick pointed at": release position
+-- plus direction times a reach that grows with strength. Short flicks land
+-- short, wild flicks sail long, and a ±30% speed error still lands inside
+-- the outer ring, which is what keeps the mechanic hard rather than broken.
+-- The right stick plays the same game with a virtual finger standing at
+-- centre + stick * radius, so pad players flick by flicking the stick.
+--
+-- Spin is a circular gesture around the held ball: accumulated signed
+-- angle arms the curve past 1.25 turns, and once armed it latches. A
+-- curved ball hooks late in flight (the ramp is what forces aiming off to
+-- the opposite side) with a 40% assist folded into the solve, so the curve
+-- is a flourish first and a skill test second.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Mat4 = V.require("Mat4")
+local Voxel3D = V.require("Voxel3D")
+local Pokeball = V.require("Pokeball")
+local BattleScene = V.require("BattleScene")
+local BattleCam = V.require("BattleCam")
+
+local CatchThrow = {}
+
+-- the parked phase: any value BattleState:update has no branch for freezes
+-- the fight; namespaced so no future engine phase can collide with it
+CatchThrow.PHASE = "dramaticShapeCapture"
+
+-- ------- tuning
+--
+-- Screen quantities are GB pixels (the battle frame is 160x144); world
+-- quantities are world pixels (a map cell is 16); times are seconds.
+
+local WINDOW = 0.11 -- flick fit window
+local S_MIN = 55 -- below this the release is a hesitation
+-- a throw must point up-screen at least this much. LENIENT on purpose:
+-- the gate only has to reject a plainly downward drag
+local MIN_UP = 0.08
+
+-- ------- the throw is the SWIPE'S OWN VELOCITY
+--
+-- The reference model (and the one that finally felt right): the ball is
+-- dragged under the finger, and releasing LAUNCHES IT with the swipe's
+-- velocity -- forward speed from how hard the swipe was, height from its
+-- vertical, side from its horizontal, each clamped to a sane range like a
+-- rigidbody throw. No aim point is picked and no arc is solved: gravity
+-- and the collision decide what the throw earns. A soft flick drops
+-- short, a wild one sails over, and the assist below is the only mercy.
+--
+-- CALIBRATED against the staging rather than picked: the throw is 48
+-- world pixels (three cells) to a body about ten pixels up, so the
+-- vertical speed that actually ARRIVES is somewhere in the 65-95 range
+-- across every forward speed worth throwing at. The first cut had gains
+-- of 3.0 and a 150/110 ceiling, which meant an ordinary ~190 GB px/s
+-- swipe already saturated both clamps -- every throw left at maximum,
+-- and maximum was well above anything that lands, so the ball sailed
+-- over the Pokemon no matter how it was flicked. These put a
+-- comfortable swipe in the middle of the band and leave the ceilings
+-- where a genuinely hard throw still reaches them.
+local K_FWD, K_UP, K_LAT = 2.4, 1.6, 1.0 -- swipe (world px/s) -> velocity
+local F_MIN, F_MAX = 48, 140 -- forward speed clamp, world px/s
+local U_MIN, U_MAX = 34, 92 -- upward
+local L_MAX = 38 -- sideways
+-- ------- and the ceiling that ends overthrowing
+--
+-- The launch height is never allowed to exceed what THIS throw's own
+-- forward speed needs to arrive, by more than a margin. That is the
+-- direct cure for the failure the gains alone could not fix: on a fixed
+-- camera there is no depth cue for "too high", so a firm flick would
+-- sail the ball clean over the Pokemon every time and the player had no
+-- way to read why. Under the cap a throw can still fall SHORT (too
+-- weak) or go WIDE (bad bearing) -- both of which are legible, because
+-- you watch the ball land -- but it can no longer be lost over the top.
+local OVER_CAP = 1.12
+-- directional assist: the HORIZONTAL launch direction leans this fraction
+-- toward the Pokemon. Speed, height and the short/long answer stay the
+-- player's; the assist only trims the bearing, so an honest throw at the
+-- creature connects instead of clipping past its ear.
+local MAGNET = 0.3
+-- range assist: the launch's HEIGHT leans this fraction toward what its
+-- own forward speed would need to arrive at the foe's body. Pure velocity
+-- throws punish the lob's shape as hard as its speed; this keeps speed as
+-- the skill and stops a good read from sailing a ball-width over. Half,
+-- not a third: the arc's SHAPE is the least legible thing about a throw
+-- on a fixed camera -- there is no depth cue for how high is too high --
+-- so it is the part worth forgiving, while distance and bearing stay the
+-- player's to get right.
+local V_ASSIST = 0.5
+local T_REF = 0.6 -- the tap-throw / Master Ball solve time
+
+local GRAVITY = 300 -- world px/s^2 -- ~2x earth at 16 px/metre,
+ -- the platformer exaggeration the arc needs
+local H_STEP = 1 / 120 -- fixed integration step
+
+-- ------- spin
+--
+-- The wind-up is a METER now, not a switch: every turn of the gesture adds
+-- to it and it bleeds away when the hand stops, so the ball visibly spins
+-- up with the player -- faster the more they wind -- to a cap. Only a ball
+-- AT the cap curves when thrown; anything less is style.
+local SPIN_FULL = 32 -- radians of gesture from still to the cap
+local ROLL_MAX = 24 -- the cap's visible spin, rad/s
+local SPIN_DECAY = 0.45 -- meter per second, once the hand pauses
+local SPIN_IDLE = 0.2 -- how long a pause before the bleed starts
+local ARM_AT, DISARM_AT = 0.99, 0.80 -- hysteresis on "at the cap"
+
+local CURVE_GAIN = 120 -- lateral pull at full spin, world px/s^2
+local CURVE_RAMP0, CURVE_RAMP1 = 0.25, 0.70 -- the hook bites late
+local ASSIST = 0.4 -- how much of the curve the solve pre-corrects
+local K_RAMP = 0.14 -- integral of the ramp, for the assist
+
+local RING_PERIOD = 2.2 -- shrink cycle
+local RING_HOLD = 0.15 -- pause at the smallest
+local RING_MIN = 0.12 -- the Excellent floor
+local CHEST = 9 -- ring/beam height over the foe's feet, world px
+
+local SUCK_T = 0.6 -- the Pokemon drinking into the ball
+local DROP_T = 0.45 -- fall to the ground
+local SHAKE_GAP = 0.42 -- pause between rocks
+local STICK_R = 34 -- the virtual finger's orbit, GB px
+
+-- stick flick thresholds, in stick units (full deflection = 1) per second
+local STICK_VEL = 5.5
+local STICK_GRAB = 0.30
+
+-- the Let's Go throw-grade multipliers, applied to the Gen 1 catch rate
+-- and to the capture-experience stack (Bulbapedia's Let's Go tables)
+local TIER_MULT = { EXCELLENT = 2.0, GREAT = 1.5, NICE = 1.1 }
+
+-- ------- session state
+
+local S = nil
+
+local function game() return require("src.core.Game") end
+
+local function sound(name)
+ pcall(function()
+ require("src.core.Sound").play(game().data, name)
+ end)
+end
+
+local function emit(name, payload)
+ pcall(function() require("src.mods.Runtime").emit(name, payload) end)
+end
+
+local function OB() return V.require("OverworldBattle") end
+
+function CatchThrow.active()
+ return S ~= nil and S.phase ~= "epilogue"
+end
+
+function CatchThrow.session()
+ return S
+end
+
+-- ------- geometry helpers
+--
+-- The shot BattleScene hands back carries the camera (eye/focus), the
+-- combined matrix (vp) and the letterbox, which together answer every
+-- space question the session has: world -> GB via BattleScene.toGB, and
+-- the camera's flat forward/right for aiming errors.
+
+local function camBasis(shot)
+ local e, f = shot.eye, shot.focus
+ local fx, fz = f[1] - e[1], f[3] - e[3]
+ local l = math.sqrt(fx * fx + fz * fz)
+ if l < 1e-6 then fx, fz = 0, -1 else fx, fz = fx / l, fz / l end
+ -- right-handed about +Y up: right = fwd x up
+ return fx, fz, -fz, fx
+end
+
+local function toGB(shot, wx, wy, wz)
+ return BattleScene.toGB(shot.vp, wx, wy, wz,
+ shot.lx, shot.ly, shot.scale, shot.pw, shot.ph)
+end
+
+-- the yaw that turns a thing at (x, z) to face this shot's eye
+local function eyeYaw(x, z)
+ local e = S.shot and S.shot.eye
+ if not e then return 0 end
+ return math.atan2(e[1] - x, e[3] - z)
+end
+
+-- ------- the capture seat's measurements
+--
+-- HEAD ON: the eye stands on the arena's own axis, behind the player's
+-- cell, looking straight at the foe -- the GO framing, not the battle's
+-- over-the-shoulder rig. Declared here rather than beside captureRig
+-- below because the ray reconstruction underneath needs the lens.
+local SEAT_BACK = 46 -- eye behind the player's cell, world px
+local SEAT_UP = 13 -- and above the floor
+local SEAT_FOV = math.rad(26)
+local SEAT_FRAME = 44 -- world height the sun's box is fitted to
+
+-- ------- the ray a GB-frame point looks along
+--
+-- The drag's whole job is "the ball is under the finger", and only the
+-- real projection answers that: an offset scaled by some world-per-pixel
+-- estimate is right at one depth and wrong everywhere else, and it has
+-- no way to say what the bottom of the screen even means. This is the
+-- ordinary pinhole reconstruction from the seat's own basis -- the
+-- vertical field covers the GB's 144 rows, and the horizontal falls out
+-- of square pixels (which is why both axes divide by the same half
+-- height).
+--
+-- Keyed to SEAT_FOV rather than to the shot, deliberately: the shot's
+-- fov has been widened to the window (BattleScene.letterboxFov) and what
+-- is wanted here is the lens the GB frame was framed with, which is the
+-- capture rig's own and nobody else's.
+local function rayThrough(gx, gy)
+ local shot = S and S.shot
+ if not shot then return nil end
+ local e, f = shot.eye, shot.focus
+ local dx, dy, dz = f[1] - e[1], f[2] - e[2], f[3] - e[3]
+ local l = math.sqrt(dx * dx + dy * dy + dz * dz)
+ if l < 1e-6 then return nil end
+ dx, dy, dz = dx / l, dy / l, dz / l
+ -- right = forward x worldUp; camUp = right x forward
+ local rx, rz = -dz, dx
+ local rl = math.sqrt(rx * rx + rz * rz)
+ if rl < 1e-6 then return nil end
+ rx, rz = rx / rl, rz / rl
+ local ux, uy, uz = -rz * dy, rz * dx - rx * dz, rx * dy
+ local half = BattleScene.GB_H / 2
+ local t = math.tan(SEAT_FOV / 2)
+ local ax = (gx - BattleScene.GB_W / 2) / half * t
+ local ay = (gy - half) / half * t
+ local vx = dx + rx * ax - ux * ay
+ local vy = dy - uy * ay
+ local vz = dz + rz * ax - uz * ay
+ local vl = math.sqrt(vx * vx + vy * vy + vz * vz)
+ if vl < 1e-6 then return nil end
+ return vx / vl, vy / vl, vz / vl
+end
+
+-- Where the held ball hangs: over the PLAYER'S OWN CELL, which capture
+-- mode has just emptied. Not in front of the camera -- the default battle
+-- rig is a long lens standing five blocks back, and anything a hand's
+-- reach from THAT eye is a blimp filling the frame. The player's cell is
+-- where the trainer stands, it sits bottom-centre of the head-on seat
+-- below, and it makes the throw a real three-cell lob at 16 pixels a
+-- metre instead of an eleven-metre crane shot from the sky.
+local HAND_HOVER = 6.5 -- the held ball's height over the floor
+local function handPos()
+ local p = S.playerPos
+ return { p[1], p[2] + HAND_HOVER, p[3] }
+end
+
+-- The seat itself, from the measurements above: the foe centred, the held
+-- ball at the bottom of the frame, the throw a straight shot up the
+-- middle. Handed to BattleScene.render through the capture table's `rig`;
+-- everything downstream (pins, cards, sun, fov) is camera-generic.
+local function captureRig(arena, groundY)
+ local ex, ez = arena.enemy[1], arena.enemy[2]
+ local px, pz = arena.player[1], arena.player[2]
+ local ax, az = ex - px, ez - pz
+ local l = math.sqrt(ax * ax + az * az)
+ if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end
+ local cam = {
+ eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK },
+ focus = { ex, groundY + 8, ez },
+ fov = SEAT_FOV,
+ }
+ -- the pitch VoxelScene.pull wants: how far below level the seat looks
+ local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l)
+ return cam, math.max(pitch, 0.05), SEAT_FRAME
+end
+
+-- The ring in GB space, centred on the CREATURE. The pinned mark is the
+-- cell's ground point, but a species' art sits wherever the artist put it
+-- in the frame -- a bird hovers half a slot over its own feet row -- so
+-- the session measures the foe's rendered pic once (measureArt below) and
+-- centres on the art's opaque box. The conversion is the card's own
+-- arithmetic: one pic pixel is FULL_W/FULL_PIC world pixels, and a cell
+-- is enemySpan GB pixels, so a pic pixel is span/56 GB pixels on screen.
+-- The heuristic fallback (no pic: a STADIUM model, or a readback that
+-- failed) is a chest height over the mark. The outer clamp is the
+-- design's 0.06..0.18 screen heights, in GB pixels.
+local function ringGeometry(shot)
+ local span = shot.enemySpan or 16
+ local b = S and S.artBox
+ if b then
+ local k = span / 56
+ local cx = shot.enemy[1] + ((b.x0 + b.x1) / 2 - b.ax) * k
+ local cy = shot.enemy[2] - (b.ay - (b.y0 + b.y1) / 2) * k
+ local r = math.max(b.x1 - b.x0, b.y1 - b.y0) * 0.55 * k
+ return cx, cy, math.max(9, math.min(26, r))
+ end
+ -- no pic to measure (a Stadium model, or a readback still pending):
+ -- the collision body doubles as the ring's anchor, in the same
+ -- world-to-screen scale a cell provides
+ local body = (S and S.body) or { r = 8, yOff = 8 }
+ local outer = math.max(9, math.min(26, body.r * 1.25 * span / 16))
+ return shot.enemy[1], shot.enemy[2] - body.yOff * span / 16, outer
+end
+
+-- read the foe's pic back once and box its opaque pixels. A few thousand
+-- getPixel calls on a 160x144 canvas, paid once per session -- the ring
+-- then sits on the Pokemon for every species without a per-species table.
+local function measureArt()
+ local ok, box = pcall(function()
+ local tex = OB().enemyTexture()
+ if not (tex and tex.canvas and tex.canvas.newImageData) then return nil end
+ local data = tex.canvas:newImageData()
+ local w, h = data:getWidth(), data:getHeight()
+ local x0, y0, x1, y1
+ for y = 0, h - 1, 2 do
+ for x = 0, w - 1, 2 do
+ local _, _, _, a = data:getPixel(x, y)
+ if a > 0.1 then
+ if not x0 or x < x0 then x0 = x end
+ if not x1 or x > x1 then x1 = x end
+ if not y0 or y < y0 then y0 = y end
+ if not y1 or y > y1 then y1 = y end
+ end
+ end
+ end
+ if not x0 then
+ if data.release then data:release() end
+ return nil
+ end
+ -- the ImageData is KEPT: it is the foe's collision geometry -- the
+ -- flight samples it per plane crossing -- and it is released when the
+ -- session is swept (endSession / battle.ended)
+ return { ax = tex.ax, ay = tex.ay, x0 = x0, y0 = y0, x1 = x1, y1 = y1,
+ data = data }
+ end)
+ return (ok and box) or nil
+end
+
+-- is there sprite under this pic pixel, within `r` pixels? Nine taps --
+-- centre and a ring -- which at the dilation radii in play reads a hit
+-- anywhere the ball's silhouette overlaps the art's.
+local function maskHit(data, x, y, r)
+ local ok, hit = pcall(function()
+ local w, h = data:getWidth(), data:getHeight()
+ local function tap(px, py)
+ px, py = math.floor(px + 0.5), math.floor(py + 0.5)
+ if px < 0 or py < 0 or px >= w or py >= h then return false end
+ local _, _, _, a = data:getPixel(px, py)
+ return a > 0.1
+ end
+ if tap(x, y) then return true end
+ for i = 0, 7 do
+ local a = i * math.pi / 4
+ if tap(x + math.cos(a) * r, y + math.sin(a) * r) then return true end
+ end
+ return false
+ end)
+ return ok and hit or false
+end
+
+-- The foe's BODY -- where the ball has to physically arrive, and what the
+-- ring is sized on. Measured from whichever the foe actually is: the pic's
+-- opaque box (a hovering bird is hit where the bird is drawn, not where
+-- its shadow falls), or the Stadium model's own height and footprint. A
+-- default torso stands in until one of them answers; measured=false says
+-- keep trying.
+local function measureBody()
+ local box = measureArt()
+ if box then
+ S.artBox = box
+ local k = 16 / 56
+ S.body = {
+ r = math.max(5, math.min(14,
+ math.max(box.x1 - box.x0, box.y1 - box.y0) * k * 0.5)),
+ yOff = math.max(4, (box.ay - (box.y0 + box.y1) / 2) * k),
+ hh = math.max(4, (box.y1 - box.y0) * k * 0.55),
+ }
+ S.bodyMeasured = true
+ return
+ end
+ local ok, body = pcall(function()
+ return V.require("Stadium").captureBody()
+ end)
+ if ok and body then
+ S.body = body
+ S.bodyMeasured = true
+ end
+end
+
+-- named for the drivers: where the ball hangs and where the ring sits, in
+-- GB pixels plus the live letterbox -- everything a scripted flick needs
+-- to aim like a hand would
+function CatchThrow._aimInfo()
+ if not (S and S.shot) then return nil end
+ local cx, cy, outer = ringGeometry(S.shot)
+ return { hand = { S.handGB[1], S.handGB[2] },
+ ring = { cx, cy }, outer = outer,
+ lx = S.shot.lx, ly = S.shot.ly, scale = S.shot.scale,
+ pw = S.shot.pw, ph = S.shot.ph }
+end
+
+-- the ring's inner fraction right now: 1 -> RING_MIN, hold, snap back
+local function ringRho(t)
+ local cycle = RING_PERIOD + RING_HOLD
+ local u = t % cycle
+ if u >= RING_PERIOD then return RING_MIN end
+ return 1 - (1 - RING_MIN) * (u / RING_PERIOD)
+end
+
+-- ------- flick estimation
+
+local function pushSample(x, y)
+ local n = S.samples
+ n[#n + 1] = { x = x, y = y, t = S.clock }
+ if #n > 32 then table.remove(n, 1) end
+end
+
+-- least-squares velocity over the window before `at`; also answers the
+-- window's peak two-sample speed and the mean speed of the last 40 ms,
+-- which the dead-flick gate reads
+local function flickFit(at)
+ local xs, n = S.samples, 0
+ local sx, sy, st = 0, 0, 0
+ local first = at - WINDOW
+ local pts = {}
+ for _, p in ipairs(xs) do
+ if p.t >= first and p.t <= at then
+ pts[#pts + 1] = p
+ sx, sy, st = sx + p.x, sy + p.y, st + p.t
+ n = n + 1
+ end
+ end
+ if n < 3 then return nil, nil, n end
+ local mt, mx, my = st / n, sx / n, sy / n
+ local num_x, num_y, den = 0, 0, 0
+ for _, p in ipairs(pts) do
+ local d = p.t - mt
+ num_x = num_x + d * (p.x - mx)
+ num_y = num_y + d * (p.y - my)
+ den = den + d * d
+ end
+ if den < 1e-9 then return nil, nil, n end
+ local vx, vy = num_x / den, num_y / den
+ local peak, tailSum, tailN = 0, 0, 0
+ for i = 2, #pts do
+ local a, b = pts[i - 1], pts[i]
+ local dt = b.t - a.t
+ if dt > 1e-5 then
+ local sp = math.sqrt((b.x - a.x) ^ 2 + (b.y - a.y) ^ 2) / dt
+ if sp > peak then peak = sp end
+ if b.t > at - 0.04 then tailSum, tailN = tailSum + sp, tailN + 1 end
+ end
+ end
+ local tail = tailN > 0 and tailSum / tailN or 0
+ return vx, vy, n, peak, tail
+end
+
+-- ------- spin gesture
+--
+-- A METER, not a switch. Circling the held ball winds it: every radian of
+-- gesture adds to the meter and the ball's screen-plane roll follows it,
+-- so the ball visibly spins faster the more it is wound, to a cap. Pause
+-- and it bleeds back down (the decay lives in the aim tick). Winding the
+-- OTHER way unwinds first. Only a ball at the cap curves when thrown.
+local function feedSpin(x, y)
+ -- About the CENTROID of the recent gesture, not about any fixed point:
+ -- the ball rides under the finger, so the finger is never orbiting the
+ -- ball -- it is orbiting the middle of the loop it is drawing, and that
+ -- is what the centroid of the last few samples is. It also gives the
+ -- straight-drag rejection for free: on a straight swipe the centroid
+ -- trails along the line, so both spokes point the same way and the
+ -- accumulated angle stays at nothing.
+ local n = #S.samples
+ if n < 4 then return end
+ local sx, sy, count = 0, 0, 0
+ for i = math.max(1, n - 11), n do
+ sx, sy, count = sx + S.samples[i].x, sy + S.samples[i].y, count + 1
+ end
+ local cx, cy = sx / count, sy / count
+ local px, py = S.spinPrev and S.spinPrev[1], S.spinPrev and S.spinPrev[2]
+ S.spinPrev = { x, y }
+ if not px then return end
+ local ax, ay = px - cx, py - cy
+ local bx, by = x - cx, y - cy
+ local ra = math.sqrt(ax * ax + ay * ay)
+ local rb = math.sqrt(bx * bx + by * by)
+ if ra < 4 or rb < 4 then return end -- thumb jitter, not an orbit
+ local d = math.atan2(ax * by - ay * bx, ax * bx + ay * by)
+ if d == 0 then return end
+ local sign = d > 0 and 1 or -1
+ if sign ~= S.spinSign and S.spinLevel > 0 then
+ -- winding against the charge unwinds it, twice as fast as it built
+ S.spinLevel = math.max(0, S.spinLevel - math.abs(d) * 2 / SPIN_FULL)
+ if S.spinLevel == 0 then S.spinSign = sign end
+ else
+ S.spinSign = sign
+ S.spinLevel = math.min(1, S.spinLevel + math.abs(d) / SPIN_FULL)
+ end
+ S.spinT = S.clock
+ if not S.spinArmed and S.spinLevel >= ARM_AT then
+ S.spinArmed = true
+ sound("Press_AB")
+ end
+end
+
+-- ------- the throw
+
+local function smoothstep(a, b, t)
+ local u = math.max(0, math.min(1, (t - a) / (b - a)))
+ return u * u * (3 - 2 * u)
+end
+
+-- the moment the ball leaves the hand: pay for it, count it, sound it
+local function paidAndAway()
+ if not S.consumed then
+ if S.safari then
+ local st = S.battle.safari
+ if st then st.balls = math.max(0, st.balls - 1) end
+ else
+ pcall(function()
+ require("src.inventory.Bag").remove(game().save, S.ballId, 1)
+ end)
+ end
+ S.consumed = true
+ end
+ S.battle.dramaticShapeThrows = (S.battle.dramaticShapeThrows or 0) + 1
+ S.phase = "flight"
+ S.tFly, S.acc = 0, 0
+ -- a charged ball keeps its screen-plane roll all the way in; a plain
+ -- throw tumbles end over end, harder the less it was wound
+ S.ballInst.tumble = S.spinArmed and 0 or 15 * (1 - S.spinLevel)
+ sound("Ball_Toss")
+end
+
+-- estimate how long the flight will take to reach the foe, for the curve
+-- ramp and the give-up timer -- with a thrown VELOCITY there is no solved
+-- T any more, only this
+local function estimateT()
+ local p, E = S.ballInst.pos, S.enemyPos
+ local dx, dz = E[1] - p[1], E[3] - p[3]
+ local horiz = math.sqrt(dx * dx + dz * dz)
+ local hs = math.sqrt(S.vel[1] ^ 2 + S.vel[3] ^ 2)
+ S.T = math.max(0.35, math.min(1.0, horiz / math.max(20, hs)))
+end
+
+-- launch with the swipe's own velocity (GB px/s in, world velocity out)
+local function launchVelocity(vgx, vgy)
+ local shot = S.shot
+ local fx, fz, rx, rz = camBasis(shot)
+ -- what a GB pixel is worth at the BALL's own depth: the swipe moved the
+ -- ball, so the ball's speed is the swipe's speed in its own plane
+ local wpg = 16 / math.max(6, shot.playerSpan or 16)
+ local speed = math.sqrt(vgx * vgx + vgy * vgy) * wpg
+ local fwd = math.max(F_MIN, math.min(F_MAX, speed * K_FWD))
+ local up = math.max(U_MIN, math.min(U_MAX, (-vgy) * wpg * K_UP))
+ local lat = math.max(-L_MAX, math.min(L_MAX, vgx * wpg * K_LAT))
+ -- the horizontal launch, leaned MAGNET of the way toward the Pokemon
+ local hx, hz = fx * fwd + rx * lat, fz * fwd + rz * lat
+ local hmag = math.sqrt(hx * hx + hz * hz)
+ local p, E = S.ballInst.pos, S.enemyPos
+ local tx, tz = E[1] - p[1], E[3] - p[3]
+ local tl = math.sqrt(tx * tx + tz * tz)
+ if hmag > 1e-6 and tl > 1e-6 then
+ local nx = hx / hmag * (1 - MAGNET) + tx / tl * MAGNET
+ local nz = hz / hmag * (1 - MAGNET) + tz / tl * MAGNET
+ local nl = math.sqrt(nx * nx + nz * nz)
+ hx, hz = nx / nl * hmag, nz / nl * hmag
+ end
+ -- the range assist: what height would land THIS forward speed on the
+ -- body, and lean toward it
+ if hmag > 1 then
+ local body = S.body or { yOff = 8 }
+ local tt = tl / hmag
+ local need = (S.groundY + body.yOff - p[2] + 0.5 * GRAVITY * tt * tt) / tt
+ up = up + (need - up) * V_ASSIST
+ -- and never more than a margin over what this throw needs (OVER_CAP)
+ if need > 0 then up = math.min(up, need * OVER_CAP) end
+ up = math.max(U_MIN * 0.6, math.min(U_MAX, up))
+ end
+ S.vel = { hx, up, hz }
+ estimateT()
+ paidAndAway()
+end
+
+-- the solved arc, kept for exactly two throwers: the Master Ball (which
+-- must not miss) and the pad's A tap (full participation without the
+-- dexterity requirement). Flies to the foe's own body centre.
+local function launchSolve()
+ local body = S.body or { r = 10, yOff = 8 }
+ local E, p = S.enemyPos, S.ballInst.pos
+ local T = T_REF
+ S.vel = { (E[1] - p[1]) / T,
+ (S.groundY + body.yOff - p[2]) / T + 0.5 * GRAVITY * T,
+ (E[3] - p[3]) / T }
+ S.T = T
+ paidAndAway()
+end
+
+local function tapThrow()
+ if S.shot then launchSolve() end
+end
+
+local function pointerRelease(at)
+ if not S.grab then return end
+ S.grab = nil
+ local vx, vy, n, peak, tail = flickFit(at or S.clock)
+ if not vx or n < 3 then return end -- a tap, not a throw
+ local speed = math.sqrt(vx * vx + vy * vy)
+ -- dead flick: the finger braked before lifting -- the player changed
+ -- their mind, and the ball drifts back to the hand
+ if peak and peak > 0 and tail / peak < 0.10 then return end
+ if speed < S_MIN then return end
+ if vy / speed > -MIN_UP then return end -- throws point up the screen
+ local def = S.battle:ballDef(S.ballId)
+ if def and def.autoCatch then return launchSolve() end
+ launchVelocity(vx, vy)
+end
+
+-- ------- contact
+
+local function tierFor(rho, inside)
+ if not inside then return nil end
+ if rho <= 0.30 then return "EXCELLENT" end
+ if rho <= 0.70 then return "GREAT" end
+ return "NICE"
+end
+
+local function beginSuck(cross)
+ S.phase = "suck"
+ S.t = 0
+ S.ballInst.pos = cross
+ S.ballInst.tumble, S.ballInst.tumbleAngle = 0, 0
+ S.ballInst.spin, S.ballInst.spinAngle = 0, 0
+ S.ballInst.roll, S.ballInst.rollAngle = 0, 0
+ -- face the foe: the hinge is at the back, so the mouth gapes at it
+ local E = S.enemyPos
+ S.ballInst.yaw = math.atan2(E[1] - cross[1], E[3] - cross[3]) + math.pi
+ S.ballInst:open()
+ sound("Ball_Poof")
+end
+
+-- The ball has TOUCHED the Pokemon -- contact is a real collision in
+-- world space (see the flight step), so any touch opens the ball; the
+-- ring's only job is the GRADE, judged in screen space at this instant:
+-- inside the shrinking ring earns the tier, outside is a plain hit.
+local function resolveContact(cross)
+ local shot = S.shot
+ local cx, cy, outer = ringGeometry(shot)
+ local rho = ringRho(S.ringT)
+ local inside = false
+ local gx, gy = toGB(shot, cross[1], cross[2], cross[3])
+ if gx then
+ local dist = math.sqrt((gx - cx) ^ 2 + (gy - cy) ^ 2)
+ inside = dist <= rho * outer + 1
+ end
+ S.tier = tierFor(rho, inside)
+ S.rho = rho
+ S.hit = true
+ -- hang the ball where it struck
+ beginSuck({ cross[1], math.max(S.groundY + Pokeball.R, cross[2]),
+ cross[3] })
+ return true
+end
+
+-- ------- the engine roll, and the queue that finishes the fight
+
+local function throwMult()
+ return (S.tier and TIER_MULT[S.tier]) or 1.0
+end
+
+local function rollCatch()
+ local b = S.battle
+ local base = S.safari and (b.safariCatchRate or b.enemy.def.catchRate)
+ or b.enemy.def.catchRate
+ local rate = math.min(255, math.floor(base * throwMult()))
+ local caught, shakes = b:catchAttempt(S.ballId, rate)
+ S.caught = caught
+ S.shakes = caught and 3 or (shakes or 0)
+ emit("battle.ball_thrown", { battle = b, ball = S.ballId,
+ caught = caught, shakes = shakes })
+end
+
+-- the camera hold's previous value, owned by whoever engaged first -- the
+-- battle-long veil under FULL, or a lone session under CATCH ONLY
+local heldStill = nil
+
+local function releaseMask()
+ if S and S.artBox and S.artBox.data then
+ pcall(function() S.artBox.data:release() end)
+ S.artBox.data = nil
+ end
+end
+
+local function endSession(keepScene)
+ if not S then return end
+ if not keepScene then
+ BattleCam.still = heldStill or false
+ heldStill = nil
+ BattleScene.capture = nil
+ releaseMask()
+ S = nil
+ end
+end
+
+-- the catch: the engine's own captured flow, with the fanfare the anim
+-- chain would have supplied. The session stays alive as an epilogue so the
+-- ball and the stars remain in shot under the caught text; battle.ended
+-- sweeps it away.
+local function resolveCaught()
+ local b = S.battle
+ b.lastBall = S.ballId
+ -- the experience context has to exist before storeCaughtMon fires the
+ -- catch-exp hooks; LetsGo owns the arithmetic
+ pcall(function()
+ V.require("LetsGo").noteCatch(b, {
+ tier = S.tier,
+ mult = throwMult(),
+ firstThrow = (b.dramaticShapeThrows or 1) == 1,
+ safari = S.safari,
+ })
+ end)
+ b.phase = "messages"
+ b.afterQueue = "menu"
+ b:act(function() sound("Caught_Mon") end)
+ b:say(require("src.core.Strings")("All right!\n%s was\ncaught!",
+ b.enemy.name))
+ b:act(function() b:storeCaughtMon() end)
+ S.phase = "epilogue"
+end
+
+-- a failed throw -- broke out, or never touched the Pokemon at all. The
+-- engine's own miss line for the shake count, then the foe's turn, exactly
+-- as throwBall's failure path builds it.
+--
+-- Except under FULL, where a wild encounter IS the catch: the foe never
+-- takes a turn, and the session stays alive as "await" -- the moment the
+-- miss text clears, the next ball is in the hand. Let's Go wilds do not
+-- fight back, and the player is never dropped out of throwing mode.
+local function resolveFail(shakes)
+ local b = S.battle
+ b.lastBall = S.ballId
+ b.phase = "messages"
+ b.afterQueue = "menu"
+ b:say(b:ballMissMessage(shakes))
+ if S.fullWild then
+ S.phase = "await"
+ S.ballInst.visible = false
+ S.hit, S.tier, S.planeD = nil, nil, nil
+ if BattleScene.capture then BattleScene.capture.shrink = nil end
+ return
+ end
+ if S.safari then
+ b:act(function() b:safariEnemyTurn() end)
+ else
+ b:act(function() b:executeAction(b.enemy, b.player, b:enemyAction()) end)
+ b:act(function() b:endOfTurn() end)
+ end
+ endSession()
+end
+
+-- B during the aim. Under FULL it is the encounter's RUN: a Let's Go wild
+-- is a catch encounter, so leaving it always works -- the safari's own
+-- farewell. Everywhere else it backs out to whatever the session was
+-- opened over, refunding a ball the bag menu already consumed.
+function CatchThrow.cancel(declined)
+ if not (S and S.phase == "aim") then return false end
+ local b = S.battle
+ if S.fullWild then
+ sound("Run")
+ b:say(require("src.core.Strings")("Got away safely!"))
+ b.result = "run"
+ b.phase = "messages"
+ b.afterQueue = "finish"
+ S.phase = "epilogue" -- the veil holds until battle.ended sweeps
+ return true
+ end
+ if S.consumed and not S.safari then
+ pcall(function()
+ require("src.inventory.Bag").add(game().save, S.ballId, 1, game().data)
+ end)
+ end
+ if declined then b.dramaticShapeDeclined = true end
+ b.phase = "menu"
+ endSession()
+ sound("Press_AB")
+ return true
+end
+
+-- ------- the scene hooks BattleScene consults
+--
+-- One table on BattleScene while a session runs: the ball's draw and
+-- shadow, the foe's shrink, the hidden player side, and the GB overlay.
+
+local function sceneDraw(pull)
+ if not S then return end
+ S.ballInst:draw(pull)
+ if S.phase == "suck" then
+ local E = S.enemyPos
+ local k = 1 - smoothstep(0, 1, S.t / SUCK_T)
+ S.ballInst:drawBeam(E[1], S.groundY + CHEST * 0.8, E[3],
+ 3.2 * k + 0.6, 1, pull)
+ end
+end
+
+local function sceneCast(shadowMap)
+ if S then S.ballInst:cast(shadowMap) end
+end
+
+local function sceneSig()
+ if not S then return "" end
+ return S.ballInst:signature() .. "," .. S.phase
+end
+
+-- ------- the GB overlay (rings, labels, ball readout)
+--
+-- Drawn inside the battle's own 160x144 UI canvas, from the BattleState
+-- draw wrap, so it composites exactly where the engine's HUD does and
+-- comes out with the same chunky letterboxed pixels.
+
+local Font = nil
+local function font()
+ if Font ~= nil then return Font or nil end
+ local ok, F = pcall(require, "src.render.Font")
+ Font = ok and F or false
+ return Font or nil
+end
+
+local function label(str, x, y, align)
+ local F = font()
+ if not F then return end
+ local g = love.graphics
+ local w = F.width(str) + 4
+ local px = align == "right" and (x - w) or
+ align == "center" and math.floor(x - w / 2) or x
+ g.setColor(0.06, 0.05, 0.09, 0.75)
+ g.rectangle("fill", px, y, w, 11)
+ g.setColor(0.93, 0.94, 0.90, 1)
+ g.rectangle("fill", px + 1, y + 1, w - 2, 9)
+ g.setColor(0, 0, 0, 1)
+ F.draw(str, px + 2, y + 2)
+ g.setColor(1, 1, 1, 1)
+end
+
+-- a tiny ball glyph for the readout
+local BALL_TOP = {
+ POKE_BALL = { 0.86, 0.16, 0.16 }, GREAT_BALL = { 0.25, 0.45, 0.88 },
+ ULTRA_BALL = { 0.22, 0.22, 0.26 }, MASTER_BALL = { 0.48, 0.22, 0.66 },
+ SAFARI_BALL = { 0.47, 0.52, 0.26 },
+}
+
+local function ballGlyph(x, y, id)
+ local g = love.graphics
+ local top = BALL_TOP[id] or BALL_TOP.POKE_BALL
+ g.setColor(0.93, 0.94, 0.90, 1)
+ g.circle("fill", x, y, 4)
+ g.setColor(top[1], top[2], top[3], 1)
+ g.arc("fill", x, y, 4, math.pi, 2 * math.pi)
+ g.setColor(0.08, 0.08, 0.09, 1)
+ g.setLineWidth(1)
+ g.line(x - 4, y, x + 4, y)
+ g.circle("line", x, y, 4)
+ g.setColor(1, 1, 1, 1)
+end
+
+-- how sure the ring should look: the Gen 1 odds, ball and status included,
+-- but never the throw -- a colour that jittered with your own inputs would
+-- be unreadable (and is why the reference game excludes it too)
+local function ringColor()
+ local b = S.battle
+ local ok, p = pcall(function()
+ local def = b:ballDef(S.ballId)
+ if def and def.autoCatch then return 1 end
+ local rate = S.safari and (b.safariCatchRate or 0) or b.enemy.def.catchRate
+ local randMax = (def and def.randMax) or 255
+ return math.max(0.05, math.min(1, (rate + 1) / (randMax + 1)))
+ end)
+ if not ok then p = 0.5 end
+ return 1 - p * 0.75, 0.25 + p * 0.7, 0.28, p
+end
+
+local function drawGB(battle)
+ if not S or S.battle ~= battle then return end
+ local shot = OB().shot()
+ if not shot then return end
+ S.shot = shot
+ local g = love.graphics
+ g.push("all")
+
+ if S.phase == "aim" or S.phase == "flight" then
+ local cx, cy, outer = ringGeometry(shot)
+ local rho = ringRho(S.ringT)
+ g.setLineWidth(1)
+ -- outer: fixed, white, the hit area
+ g.setColor(1, 1, 1, 0.85)
+ g.circle("line", cx, cy, outer)
+ -- inner: the timing ring, coloured by the odds
+ local r, gr, bl = ringColor()
+ g.setColor(r, gr, bl, 0.95)
+ g.circle("line", cx, cy, math.max(1.5, rho * outer))
+ end
+
+ if S.phase == "aim" then
+ -- the ball readout: which ball, how many left
+ local count
+ if S.safari then
+ count = battle.safari and battle.safari.balls or 0
+ else
+ count = (game().save.inventory[S.ballId] or 0)
+ + ((S.consumed and not S.safari) and 1 or 0)
+ end
+ local name = (game().data.items[S.ballId]
+ and game().data.items[S.ballId].name) or S.ballId
+ -- along the bottom edge, out of the throwing lane: with the empty
+ -- text box gone the whole middle of the frame is the wind-up, and
+ -- chrome parked in it would be something to drag around
+ -- " BALL" is dropped from the name: the glyph beside it is already a
+ -- ball, in that tier's own colours, and the full string is wide
+ -- enough at the GB font to run under the B:BACK plate opposite
+ ballGlyph(8, 137, S.ballId)
+ label(("%s x%d"):format(name:gsub("%s*BALL%s*", ""), count), 15, 131)
+ label("B:BACK", 158, 131, "right")
+ if S.canSwitch then label("L/R:BALL", 15, 119) end
+
+ -- the wind-up readout: sparks orbit the ball as it charges, dim and
+ -- slow at a quarter wind, bright gold and quick at the cap -- the cap
+ -- is the only wind that curves, so it has to be unmistakable
+ if S.spinLevel > 0.12 then
+ local bx, by = S.handGB[1], S.handGB[2]
+ local orbit = math.max(6, (shot.playerSpan or 16) * 0.2)
+ local lvl = S.spinLevel
+ if S.spinArmed then
+ g.setColor(1, 0.9, 0.35, 1)
+ else
+ g.setColor(1, 1, 0.75, 0.25 + lvl * 0.55)
+ end
+ for i = 0, 2 do
+ local a = S.clock * (3 + 8 * lvl) * S.spinSign + i * (2 * math.pi / 3)
+ g.circle("fill", bx + math.cos(a) * orbit,
+ by + math.sin(a) * orbit * 0.45, S.spinArmed and 1.5 or 1)
+ end
+ end
+ end
+
+ -- the grade, splashed at the ring for a beat after contact
+ if S.tier and S.tierSplash and S.tierSplash > 0 then
+ local cx, cy = ringGeometry(shot)
+ label(S.tier .. "!", cx, math.max(8, cy - 20), "center")
+ end
+
+ g.pop()
+end
+
+-- ------- ball switching (the FULL entry owns its ball choice)
+
+local SWITCHABLE = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
+
+local function ownedBalls()
+ local inv = game().save.inventory
+ local out = {}
+ for _, id in ipairs(SWITCHABLE) do
+ if (inv[id] or 0) > 0 then out[#out + 1] = id end
+ end
+ return out
+end
+
+local function switchBall(dir)
+ if not (S and S.canSwitch and S.phase == "aim") then return end
+ local owned = ownedBalls()
+ if #owned == 0 then return end
+ local at = 1
+ for i, id in ipairs(owned) do
+ if id == S.ballId then at = i break end
+ end
+ S.ballId = owned[((at - 1 + dir) % #owned) + 1]
+ S.ballInst.ball = S.ballId
+ sound("Press_AB")
+end
+
+CatchThrow.pickBall = function()
+ local owned = ownedBalls()
+ for _, id in ipairs(owned) do
+ if id == CatchThrow.lastBall then return id end
+ end
+ return owned[1]
+end
+
+-- ------- session lifecycle
+
+-- The capture table BattleScene consults: one shape, installed by the
+-- session OR by the battle-long veil below.
+local function installScene()
+ if not BattleScene.capture then
+ if heldStill == nil then heldStill = BattleCam.still end
+ BattleCam.still = true
+ end
+ BattleScene.capture = {
+ hidePlayer = true,
+ rig = captureRig,
+ draw = sceneDraw,
+ cast = sceneCast,
+ sig = sceneSig,
+ drawGB = drawGB,
+ shrink = nil,
+ }
+end
+
+-- The FULL veil: installed at battle.started for a Let's Go wild, before
+-- any session exists, so the whole encounter -- the wipe, the "Wild X
+-- appeared!" line, every beat between throws -- plays from the head-on
+-- seat with the player's side out of the shot. The player's Pokemon is
+-- NEVER shown; the encounter IS the catch.
+function CatchThrow.veil(battle)
+ installScene()
+end
+
+-- opts: consumed (the bag already took the ball), safari (safari flow),
+-- canSwitch (aim-time ball cycling), declinable (B backs out / runs),
+-- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees)
+function CatchThrow.begin(battle, ballId, opts)
+ if S then return false end
+ opts = opts or {}
+ local ok = pcall(function()
+ local arena, groundY = OB().arenaInfo()
+ local shot = OB().shot()
+ assert(arena and shot and shot.eye, "no staged shot to capture in")
+ local ball = Pokeball.new(ballId)
+ S = {
+ battle = battle,
+ ballId = ballId,
+ safari = opts.safari or false,
+ fullWild = opts.fullWild or false,
+ consumed = opts.consumed or false,
+ canSwitch = opts.canSwitch or false,
+ declinable = opts.declinable ~= false,
+ phase = "aim",
+ clock = 0, t = 0, ringT = 0,
+ samples = {}, grab = nil,
+ spinLevel = 0, spinArmed = false, spinSign = 1,
+ stickHeld = false, stickSamples = {},
+ ballInst = ball,
+ enemyPos = { arena.enemy[1], groundY, arena.enemy[2] },
+ playerPos = { arena.player[1], groundY, arena.player[2] },
+ groundY = groundY,
+ shot = shot,
+ handGB = { 80, 110 },
+ }
+ ball.pos = handPos()
+ S.body = { r = 10, yOff = 8, hh = 8 } -- until a measurement lands
+ measureBody()
+ end)
+ if not ok or not S then
+ S = nil
+ return false
+ end
+ battle.phase = CatchThrow.PHASE
+ CatchThrow.lastBall = ballId
+ -- the capture seat, HELD: still drops the drift, the steer and the lift
+ -- inside the rig itself, and OverworldBattle's per-frame steerable gate
+ -- closes every input that could move it. The player's own steered angle
+ -- and lens come back when the hold releases.
+ installScene()
+ return true
+end
+
+-- the next ball into the hand, without tearing the seat down: FULL's
+-- "always in throw mode" between one throw and the next
+local function rearm(ballId)
+ S.ballId = ballId
+ CatchThrow.lastBall = ballId
+ S.ballInst = Pokeball.new(ballId)
+ S.ballInst.pos = handPos()
+ S.battle.phase = CatchThrow.PHASE -- park the engine's menu again
+ S.phase = "aim"
+ S.clock = 0
+ S.samples, S.grab, S.spinPrev = {}, nil, nil
+ S.spinLevel, S.spinArmed, S.spinSign = 0, false, 1
+ S.stickHeld, S.stickSamples = false, {}
+ S.consumed = false
+ S.hit, S.tier, S.rho, S.caught, S.shakes, S.planeD = nil, nil, nil, nil,
+ nil, nil
+ S.tFly, S.tierSplash = 0, nil
+end
+
+-- battle.ended sweeps the epilogue, the veil, and any half-open session:
+-- a battle torn down by a script must never leave the camera held
+function CatchThrow.onBattleEnded()
+ releaseMask()
+ if heldStill ~= nil or S ~= nil or BattleScene.capture ~= nil then
+ BattleCam.still = heldStill or false
+ end
+ heldStill = nil
+ BattleScene.capture = nil
+ S = nil
+end
+
+-- ------- input entry points (installed once, from LetsGo.install)
+
+local installed = false
+
+-- window coordinates (LOVE units) -> GB frame pixels
+local function winToGB(x, y)
+ local shot = S and S.shot
+ if not shot then return nil end
+ local uw, uh = love.graphics.getDimensions()
+ if not (uw > 0 and uh > 0) then return nil end
+ local px = x * shot.pw / uw
+ local py = y * shot.ph / uh
+ return (px - shot.lx) / shot.scale, (py - shot.ly) / shot.scale
+end
+
+local function aiming()
+ return S ~= nil and S.phase == "aim"
+end
+
+local function pointer(kind, x, y, id)
+ local gx, gy = winToGB(x, y)
+ if not gx then return end
+ if kind == "press" and not S.grab then
+ S.grab = { id = id }
+ S.samples = {}
+ S.spinPrev = nil
+ pushSample(gx, gy)
+ elseif S.grab and S.grab.id == id then
+ pushSample(gx, gy)
+ feedSpin(gx, gy)
+ if kind == "release" then pointerRelease(S.clock) end
+ end
+end
+
+function CatchThrow.installInput()
+ if installed then return end
+ installed = true
+
+ local Game = require("src.core.Game")
+
+ -- ------- mouse
+ --
+ -- Installed after CamControl's wraps (see main.lua's install order), so
+ -- these are the OUTER ones and the aim owns the button first. Bare
+ -- motion is not claimed -- CamControl's battle steer already stands
+ -- down while the capture holds the camera (BattleCam.steerable).
+ do
+ local inner = love.mousepressed
+ love.mousepressed = function(x, y, button, istouch, presses)
+ if aiming() and not istouch and button == 1 then
+ pointer("press", x, y, "mouse")
+ return
+ end
+ if inner then return inner(x, y, button, istouch, presses) end
+ end
+ end
+ do
+ local inner = love.mousemoved
+ love.mousemoved = function(x, y, dx, dy, istouch)
+ if aiming() and not istouch and S.grab and S.grab.id == "mouse" then
+ pointer("move", x, y, "mouse")
+ return
+ end
+ if inner then return inner(x, y, dx, dy, istouch) end
+ end
+ end
+ do
+ local inner = love.mousereleased
+ love.mousereleased = function(x, y, button, istouch, presses)
+ if aiming() and not istouch and button == 1
+ and S.grab and S.grab.id == "mouse" then
+ pointer("release", x, y, "mouse")
+ return
+ end
+ if inner then return inner(x, y, button, istouch, presses) end
+ end
+ end
+
+ -- ------- touch
+ --
+ -- A finger on open screen is the throw; fingers on the overlay's d-pad
+ -- and buttons flow to TouchControls untouched, so B still backs out on
+ -- a phone. One finger owns the ball at a time, the FirstPerson rule.
+ local TouchControls = require("src.core.TouchControls")
+ do
+ local inner = Game.touchpressed
+ function Game:touchpressed(id, x, y)
+ if aiming() then
+ local onControl = nil
+ pcall(function() onControl = TouchControls:hitTest(x, y) end)
+ if not onControl and not S.grab then
+ pointer("press", x, y, id)
+ return
+ end
+ end
+ return inner(self, id, x, y)
+ end
+ end
+ do
+ local inner = Game.touchmoved
+ function Game:touchmoved(id, x, y)
+ if aiming() and S.grab and S.grab.id == id then
+ pointer("move", x, y, id)
+ return
+ end
+ return inner(self, id, x, y)
+ end
+ end
+ do
+ local inner = Game.touchreleased
+ function Game:touchreleased(id, x, y)
+ if aiming() and S.grab and S.grab.id == id then
+ pointer("release", x, y, id)
+ return
+ end
+ return inner(self, id, x, y)
+ end
+ end
+end
+
+-- ------- the stick, polled
+--
+-- FirstPerson's always-on wrap records the right stick whatever the mode,
+-- so the session reads it rather than claiming a seam of its own. The
+-- stick plays "virtual finger": deflection stands a finger at centre +
+-- stick * STICK_R, circling winds the spin, and a hard upward flick of
+-- the stick is the throw -- strength from the flick's own velocity.
+local function stickInput(dt)
+ local FirstPerson = V.require("FirstPerson")
+ local sx = FirstPerson.stickX and FirstPerson.stickX() or 0
+ local sy = FirstPerson.stickY and FirstPerson.stickY() or 0
+ local mag = math.sqrt(sx * sx + sy * sy)
+
+ local st = S.stickSamples
+ st[#st + 1] = { x = sx, y = sy, t = S.clock }
+ if #st > 16 then table.remove(st, 1) end
+
+ if mag > STICK_GRAB then
+ if not S.stickHeld then
+ S.stickHeld = true
+ if not S.grab then
+ S.grab = { id = "stick" }
+ S.samples = {}
+ S.spinPrev = nil
+ end
+ end
+ if S.grab and S.grab.id == "stick" then
+ local gx = S.handGB[1] + sx * STICK_R
+ local gy = S.handGB[2] + sy * STICK_R
+ pushSample(gx, gy)
+ feedSpin(gx, gy)
+ -- the flick: stick velocity over the last few samples, upward and
+ -- fast. Measured in stick units so a worn pad and a fresh one
+ -- answer alike after their deadzones.
+ local n = #st
+ if n >= 3 then
+ local a, b = st[n - 2], st[n]
+ local dt2 = b.t - a.t
+ if dt2 > 1e-4 then
+ local vy = (b.y - a.y) / dt2
+ if vy < -STICK_VEL and sy < -0.45 then
+ -- hand the same release path the pointer takes; the samples
+ -- already trace the gesture in GB pixels
+ pointerRelease(S.clock)
+ S.stickHeld = false
+ end
+ end
+ end
+ end
+ elseif S.stickHeld then
+ S.stickHeld = false
+ if S.grab and S.grab.id == "stick" then
+ -- stick returned to centre without the flick: let the fit decide
+ -- whether the travel back was itself a throw (it usually is not)
+ pointerRelease(S.clock)
+ end
+ end
+end
+
+-- ------- per-frame drive (called from LetsGo.update, before the battle
+-- scene renders, so the ball this frame draws is the ball this frame)
+
+function CatchThrow.update(dt)
+ if not S then return end
+ local b = S.battle
+ S.clock = S.clock + dt
+ S.ringT = S.ringT + dt
+ S.ballInst:update(dt)
+ if S.tierSplash then S.tierSplash = S.tierSplash - dt end
+ -- the box is back by default and taken away only by the aim branch, so
+ -- the first frame with something to say has somewhere to say it
+ if BattleScene.capture then BattleScene.capture.hideTextBox = false end
+
+ -- the battle went away under us (forced finish, a script): stand down
+ if S.phase ~= "epilogue" then
+ local ok, gone = pcall(function()
+ return b.result ~= nil and b.result ~= false
+ end)
+ if ok and gone then
+ CatchThrow.onBattleEnded()
+ return
+ end
+ end
+
+ -- the staged shot for this frame's reasoning: last frame's render.
+ -- Losing it (a lost context, a broken arena) abandons the session
+ -- rather than playing blind.
+ local shot = OB().shot()
+ if shot then
+ S.shot = shot
+ S.lostShot = 0
+ else
+ S.lostShot = (S.lostShot or 0) + dt
+ if S.lostShot > 1.0 and S.phase == "aim" then
+ CatchThrow.cancel(true)
+ return
+ end
+ end
+
+ if S.phase == "aim" then
+ -- the foe's body, if no measurement landed at begin (the texture pass
+ -- and the session race on the entry frame; a model needs its pack
+ -- warm): retried while aiming, briefly -- the default torso covers a
+ -- foe that never answers
+ if not S.bodyMeasured and (S.artTries or 0) < 30 then
+ S.artTries = (S.artTries or 0) + 1
+ measureBody()
+ end
+ -- The held ball is DRAGGED, riding directly under the finger with no
+ -- fence around it (the reference model: the ball follows the hand,
+ -- and the throw will be the hand's own velocity). It moves in the
+ -- CAMERA-FACING PLANE through its hover -- screen right is the eye's
+ -- right, screen up is the eye's own tilted up -- so every part of
+ -- the frame maps to somewhere the ball can be: dragging low brings
+ -- it down and toward the seat rather than jamming it into the floor.
+ -- The floor is the one true limit, and it is the world's, not a box.
+ local home = handPos()
+ local bob = math.sin(S.clock * 2.2) * 0.35
+ local target = { home[1], home[2] + bob, home[3] }
+ if S.grab and #S.samples > 0 then
+ -- straight down the finger's own ray, at the hand's reach: the ball
+ -- is wherever the pointer is, over the WHOLE frame, with no plane
+ -- and no box to run into.
+ --
+ -- The one limit is the world's own floor, and it is not a clamp:
+ -- drag low enough that the ray would put the ball underground and
+ -- the ball is pulled IN toward the eye instead, which is how a
+ -- hand holding it low actually looks. So the bottom of the screen
+ -- is reachable everywhere -- the ball simply comes nearer as it
+ -- goes down, rather than stopping dead at the grass.
+ local last = S.samples[#S.samples]
+ local e = S.shot.eye
+ local dx, dy, dz = rayThrough(last.x, last.y)
+ if dx then
+ local reach = math.sqrt((home[1] - e[1]) ^ 2 + (home[2] - e[2]) ^ 2
+ + (home[3] - e[3]) ^ 2)
+ if dy < -1e-6 then
+ local floor = (e[2] - (S.groundY + Pokeball.R * 1.4)) / -dy
+ reach = math.min(reach, math.max(Pokeball.R * 3, floor))
+ end
+ target = { e[1] + dx * reach, e[2] + dy * reach, e[3] + dz * reach }
+ end
+ end
+ -- direct under the finger, a soft drift when coming home
+ local ease = S.grab and math.min(1, dt * 30) or math.min(1, dt * 9)
+ local bp = S.ballInst.pos
+ bp[1] = bp[1] + (target[1] - bp[1]) * ease
+ bp[2] = bp[2] + (target[2] - bp[2]) * ease
+ bp[3] = bp[3] + (target[3] - bp[3]) * ease
+ -- face the seat, so the wind-up's roll reads as the ball turning
+ -- clockwise/counter-clockwise to the player watching it
+ S.ballInst.yaw = eyeYaw(bp[1], bp[3])
+ local gx, gy = toGB(S.shot, bp[1], bp[2], bp[3])
+ if gx then S.handGB = { gx, gy } end
+
+ -- the wind-up meter: bleeds once the hand pauses, and the ball's
+ -- visible roll IS the meter -- faster the more it is wound
+ if S.spinLevel > 0 and S.clock - (S.spinT or 0) > SPIN_IDLE then
+ S.spinLevel = math.max(0, S.spinLevel - SPIN_DECAY * dt)
+ end
+ if S.spinArmed and S.spinLevel < DISARM_AT then S.spinArmed = false end
+ S.ballInst.roll = -S.spinSign * S.spinLevel * ROLL_MAX
+ -- the empty text box is off the frame while aiming, which is what
+ -- gives the drag the bottom third of the screen to wind up in
+ if BattleScene.capture then BattleScene.capture.hideTextBox = true end
+
+ stickInput(dt)
+
+ -- a beat of deafness on entry: the A that picked the ball in the bag
+ -- menu is still this frame's edge, and it must not become the throw
+ if S.clock < 0.25 then return end
+ local input = game().input
+ if input and input.wasPressed then
+ if S.declinable and input:wasPressed("b") then
+ CatchThrow.cancel(true)
+ return
+ end
+ if input:wasPressed("a") then tapThrow() end
+ if input:wasPressed("left") then switchBall(-1) end
+ if input:wasPressed("right") then switchBall(1) end
+ end
+ return
+ end
+
+ -- FULL, between throws: the miss text is playing, the seat is held, and
+ -- the moment the engine offers the menu the next ball is in the hand
+ -- instead. Out of balls, the hold releases and the classic menu takes
+ -- over -- there is nothing left to be in throw mode WITH.
+ if S.phase == "await" then
+ if S.battle.phase == "menu" then
+ local ball = CatchThrow.pickBall()
+ if ball then
+ rearm(ball)
+ else
+ endSession()
+ end
+ end
+ return
+ end
+
+ if S.phase == "flight" then
+ S.acc = S.acc + dt
+ local E = S.enemyPos
+ -- The foe's GEOMETRY, not a blob. A pic foe is judged against its own
+ -- art: crossing the card's plane samples the sprite's opaque pixels
+ -- (dilated by the ball's radius and a mercy margin), so a ball through
+ -- the gap under a wing flies on and one that clips the wing connects.
+ -- A model foe is an ellipsoid at its measured height and footprint;
+ -- the default torso covers a foe that never answered.
+ local body = S.body or { r = 10, yOff = 8, hh = 8 }
+ local box = S.artBox
+ local fx, fz, rx, rz = camBasis(S.shot)
+ local kPic = 16 / 56 -- world px per pic px
+ local dilate = (Pokeball.R * 1.6 + 2) / kPic
+ local sr = body.r * 1.3 + Pokeball.R + 1.5 -- ellipsoid semi-axes
+ local sh = (body.hh or body.r) + Pokeball.R + 1
+ while S.acc >= H_STEP do
+ S.acc = S.acc - H_STEP
+ local p, v = S.ballInst.pos, S.vel
+ local ax, ay, az = 0, -GRAVITY, 0
+ if S.spinArmed then
+ local w = smoothstep(CURVE_RAMP0, CURVE_RAMP1, S.tFly / S.T)
+ ax = ax + rx * CURVE_GAIN * S.spinSign * w
+ az = az + rz * CURVE_GAIN * S.spinSign * w
+ end
+ v[1], v[2], v[3] = v[1] + ax * H_STEP, v[2] + ay * H_STEP,
+ v[3] + az * H_STEP
+ p[1] = p[1] + v[1] * H_STEP
+ p[2] = p[2] + v[2] * H_STEP
+ p[3] = p[3] + v[3] * H_STEP
+ S.tFly = S.tFly + H_STEP
+
+ if not S.hit then
+ local contact = false
+ if box and box.data then
+ -- the card's plane through the foe's cell, crossed this step?
+ local d1 = (p[1] - E[1]) * fx + (p[3] - E[3]) * fz
+ if (S.planeD or -1) < 0 and d1 >= 0 then
+ -- where the ball stands ON the art, in pic pixels
+ local ux = (p[1] - E[1]) * rx + (p[3] - E[3]) * rz
+ local px = box.ax + ux / kPic
+ local py = box.ay - (p[2] - S.groundY) / kPic
+ contact = maskHit(box.data, px, py, dilate)
+ end
+ S.planeD = d1
+ else
+ local dx = (p[1] - E[1]) / sr
+ local dy = (p[2] - (S.groundY + body.yOff)) / sh
+ local dz = (p[3] - E[3]) / sr
+ contact = dx * dx + dy * dy + dz * dz <= 1
+ end
+ if contact then
+ resolveContact({ p[1], p[2], p[3] })
+ S.tierSplash = S.tier and 1.0 or nil
+ return
+ end
+ end
+
+ -- short or wide: the ground takes it, with a couple of tired hops
+ if p[2] <= S.groundY + Pokeball.R and v[2] < 0 then
+ p[2] = S.groundY + Pokeball.R
+ v[2] = -v[2] * 0.35
+ v[1], v[3] = v[1] * 0.55, v[3] * 0.55
+ S.ballInst.tumble = S.ballInst.tumble * 0.5
+ if math.abs(v[2]) < 12 then v[2] = 0 end
+ end
+ end
+ if S.tFly > S.T + 1.2 then
+ S.ballInst.visible = false
+ resolveFail(0) -- "You missed the POKéMON!"
+ end
+ return
+ end
+
+ if S.phase == "suck" then
+ S.t = S.t + dt
+ local k = 1 - smoothstep(0, 1, S.t / SUCK_T)
+ BattleScene.capture.shrink = math.max(0.02, k)
+ if S.t >= SUCK_T then
+ S.ballInst:close()
+ S.phase = "drop"
+ S.t = 0
+ -- the roll happens NOW, so the wobble count is honest
+ rollCatch()
+ local p = S.ballInst.pos
+ S.dropFrom = { p[1], p[2], p[3] }
+ -- land a step toward the camera from the foe's feet
+ local fx, fz = camBasis(S.shot)
+ S.dropTo = { S.enemyPos[1] - fx * 5, S.groundY + Pokeball.R,
+ S.enemyPos[3] - fz * 5 }
+ end
+ return
+ end
+
+ if S.phase == "drop" then
+ S.t = S.t + dt
+ local u = math.min(1, S.t / DROP_T)
+ local p0, p1 = S.dropFrom, S.dropTo
+ -- a dropped arc: linear across, gravity-shaped down, one bounce
+ local h = (1 - u) * (p0[2] - p1[2])
+ local bounce = 0
+ if u > 0.82 then
+ bounce = math.abs(math.sin((u - 0.82) / 0.18 * math.pi)) * 1.2 * (1 - u)
+ end
+ S.ballInst.pos = { p0[1] + (p1[1] - p0[1]) * u,
+ p1[2] + h * (1 - u * 0.4) + bounce,
+ p0[3] + (p1[3] - p0[3]) * u }
+ if u >= 1 then
+ S.ballInst.pos = { p1[1], p1[2], p1[3] }
+ S.phase = "wobble"
+ S.t = -0.35 -- a beat before the first rock
+ S.shakeIx = 0
+ sound("Tink")
+ end
+ return
+ end
+
+ if S.phase == "wobble" then
+ S.t = S.t + dt
+ if S.ballInst:busy() then return end
+ if S.t < SHAKE_GAP then return end
+ if S.shakeIx < S.shakes then
+ S.shakeIx = S.shakeIx + 1
+ S.t = 0
+ S.ballInst:rock(S.shakeIx % 2 == 0 and 1 or -1)
+ sound("Tink")
+ elseif S.caught then
+ S.phase = "clicked"
+ S.t = 0
+ S.ballInst:catchClick()
+ else
+ -- the breakout: the ball bursts, the foe pours back out
+ S.phase = "burst"
+ S.t = 0
+ S.ballInst:burst()
+ sound("Ball_Poof")
+ end
+ return
+ end
+
+ if S.phase == "clicked" then
+ S.t = S.t + dt
+ if S.t >= 0.9 then resolveCaught() end
+ return
+ end
+
+ if S.phase == "burst" then
+ S.t = S.t + dt
+ local k = smoothstep(0, 1, S.t / 0.3)
+ BattleScene.capture.shrink = k >= 0.98 and nil or math.max(0.02, k)
+ if S.t >= 0.55 then
+ BattleScene.capture.shrink = nil
+ S.ballInst.visible = false
+ resolveFail(S.shakes)
+ end
+ return
+ end
+
+ -- epilogue: the ball rests in shot while the caught text plays; the
+ -- stars fade on their own, and battle.ended clears the table
+end
+
+return CatchThrow
diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua
new file mode 100644
index 0000000..e5965a6
--- /dev/null
+++ b/lib/LetsGo.lua
@@ -0,0 +1,404 @@
+-- LET'S GO: the row, the modes, and every engine seam the capture game
+-- stands on.
+--
+-- Three rungs:
+--
+-- OFF nothing changes. The default, and what an unrecognised
+-- stored value falls back to.
+-- FULL the whole Let's Go treatment. A wild encounter opens
+-- STRAIGHT into capture mode (B backs out to the classic
+-- menu for anyone who came to fight), Poke/Great/Ultra
+-- Balls are half price at every mart, and EXPERIENCE works
+-- the way that game's does: every healthy party member
+-- gains from every catch AND every trainer knockout, each
+-- measured against its own level. A catch adds the throw
+-- stack on top -- grade, first throw, new species, combo.
+-- CATCH ONLY the fights are untouched and the shops are untouched;
+-- the one change is that throwing a ball -- from the bag,
+-- or a SAFARI BALL from the safari menu -- runs the throw
+-- minigame instead of the automatic toss. The minigame's
+-- grade still folds into the Gen 1 catch roll (a good
+-- throw should matter or the ring is a lie), but nothing
+-- outside the throw changes.
+--
+-- The capture game itself lives in lib/CatchThrow.lua and the ball it
+-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
+-- the two BattleState wraps that intercept a ball being thrown, the
+-- auto-entry tick for FULL, the price patch, and the experience hooks.
+--
+-- ------- where the minigame declines to run
+--
+-- The throw is a 3D scene: it needs the staged battle standing (the
+-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
+-- default), a driver with a depth buffer, and a flat screen (the VR seat
+-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
+-- switched off, a headless driver, a headset -- the ball quietly takes
+-- the engine's own toss, which is exactly what the mod's "declines
+-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
+-- old man's demo keep the vanilla path on purpose: those branches ARE
+-- their behaviour.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local Voxel3D = V.require("Voxel3D")
+local CatchThrow = V.require("CatchThrow")
+
+local LetsGo = {}
+
+LetsGo.KEY = "letsgo"
+LetsGo.LABEL = "LET'S GO"
+
+-- `false` first: the default, and the fallback for a stored value from
+-- some other version of this ladder
+LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
+ { false, "full", "catching" },
+ { "OFF", "FULL", "CATCH ONLY" })
+
+-- false | "full" | "catching"
+function LetsGo.mode()
+ return LetsGo.setting:get()
+end
+
+local function game() return require("src.core.Game") end
+
+-- ------- half-price balls (FULL)
+--
+-- Prices are live data (game.data.items[id].price) and every reader --
+-- the buy list, the affordability check, the quantity box -- reads them
+-- per use, so patching the table IS the feature. Applied and reverted on
+-- the option's edge, polled from the tick because the row, the manager's
+-- page and a loaded save can all move it and none of them announces to
+-- us. The sell price follows automatically (the mart pays half of list),
+-- which is coherent: cheaper balls are worth less back too.
+local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
+local fullPrices = nil -- originals while halved, or nil
+
+local function applyPrices()
+ local g = game()
+ local items = g and g.data and g.data.items
+ if not items then return end
+ local wantHalf = LetsGo.mode() == "full"
+ if wantHalf and not fullPrices then
+ fullPrices = {}
+ for _, id in ipairs(PRICED) do
+ local def = items[id]
+ if def and def.price then
+ fullPrices[id] = def.price
+ def.price = math.floor(def.price / 2)
+ end
+ end
+ elseif not wantHalf and fullPrices then
+ for id, price in pairs(fullPrices) do
+ if items[id] then items[id].price = price end
+ end
+ fullPrices = nil
+ end
+end
+
+-- ------- whether a throw can be the minigame
+
+local function vrOn()
+ local ok, vr = pcall(V.require, "VR")
+ return ok and vr and vr.enabled and vr.enabled() or false
+end
+
+function LetsGo.wantsMinigame(battle)
+ if not LetsGo.mode() then return false end
+ if not battle or battle.kind ~= "wild" then return false end
+ if battle.demo or battle.ghost or battle.noCatch then return false end
+ if not Voxel3D.available() or vrOn() then return false end
+ -- the staged shot must actually be standing: this is "there is a 3D
+ -- battle on screen right now", which the throw is aimed into
+ local ok, shot = pcall(function()
+ return V.require("OverworldBattle").shot()
+ end)
+ return (ok and shot) and true or false
+end
+
+-- A Let's Go wild: the encounters FULL owns outright. In these the foe
+-- never takes a turn, the player's Pokemon is never sent out or shown,
+-- B runs (and always escapes), and the encounter lives in throw mode
+-- from the wipe to the last message.
+function LetsGo.fullWild(battle)
+ return LetsGo.mode() == "full" and battle and battle.kind == "wild"
+ and not (battle.safari or battle.demo or battle.ghost
+ or battle.noCatch)
+ and true or false
+end
+
+-- ------- the experience stack (FULL)
+--
+-- Let's Go pays a catch like a knockout, through the Gen VII scaled
+-- formula -- every party member paid against its OWN level -- times the
+-- catch bonuses. Three engine hooks carry it:
+--
+-- battle.catch_exp "does a catch pay at all" -- yes, under FULL
+-- battle.exp_award the distribution: every healthy party member its
+-- own full share, no participant split
+-- exp.gain the amount: the scaled formula times the bonus
+-- stack, in place of floor(b*L/7)
+--
+-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
+-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
+-- combo tier. Traded 1.5 still rides through the engine's own flag.
+local expCtx = nil -- {battle, mult} while a Let's Go catch pays out
+local granting = nil -- set across the applyShare loop for exp.gain
+
+local function comboMult(n)
+ if n <= 10 then return 1.1 end
+ if n <= 20 then return 1.5 end
+ if n <= 30 then return 2.0 end
+ if n <= 40 then return 2.5 end
+ return 3.0
+end
+
+-- the catch combo, persisted with the save (mod.save rides save.modData):
+-- catching the same species again extends it, anything else restarts it
+local function bumpCombo(species)
+ local ms = V.mod and V.mod.save
+ local combo = { species = species, count = 1 }
+ if ms then
+ local ok, held = pcall(ms.get, ms, "letsgoCombo")
+ if ok and type(held) == "table" and held.species == species then
+ combo.count = (tonumber(held.count) or 0) + 1
+ end
+ pcall(ms.set, ms, "letsgoCombo", combo)
+ end
+ return combo.count
+end
+
+function LetsGo.combo()
+ local ms = V.mod and V.mod.save
+ if not ms then return nil end
+ local ok, held = pcall(ms.get, ms, "letsgoCombo")
+ return ok and type(held) == "table" and held or nil
+end
+
+-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
+-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
+-- still answerable, and the exp hooks fire inside storeCaughtMon.
+function LetsGo.noteCatch(battle, info)
+ local species = battle.enemy and battle.enemy.mon
+ and battle.enemy.mon.species
+ local chain = species and bumpCombo(species) or 1
+ if LetsGo.mode() ~= "full" then return end
+ local mult = info.mult or 1
+ if info.firstThrow then mult = mult * 1.5 end
+ local dex = game().save and game().save.pokedex
+ if dex and species and not dex.owned[species] then mult = mult * 1.1 end
+ mult = mult * comboMult(chain)
+ expCtx = { battle = battle, mult = mult }
+end
+
+-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
+-- level, +1, then the traded boost and (for a catch) the bonus stack.
+-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
+-- full share rather than a share of one.
+--
+-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
+-- absent from the catch-side write-ups of this formula for the simple
+-- reason that a caught Pokemon is always wild, so it is always 1 there --
+-- which is also why adding it leaves every catch payout exactly where it
+-- was, verified against the published table in the suite.
+local function scaledGain(c, mult)
+ local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
+ local L = c.level or 1
+ local Lp = (c.mon and c.mon.level) or L
+ local a = c.isTrainer and 1.5 or 1
+ local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
+ local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
+ if c.traded then exp = math.floor(exp * 1.5) end
+ return math.max(1, math.floor(exp * (mult or 1)))
+end
+
+LetsGo._scaledGain = scaledGain -- named for the suite
+LetsGo._comboMult = comboMult
+
+-- ------- FULL's auto-entry
+--
+-- The moment a wild battle's menu opens under FULL, capture mode opens
+-- over it, with the last ball the player threw (or the first ball in the
+-- bag). B backs out to the classic menu and stays out for that battle --
+-- the bag's own ball route still re-enters the throw.
+local function autoEnter()
+ if LetsGo.mode() ~= "full" then return end
+ if CatchThrow.active() then return end
+ local ok, battle = pcall(function()
+ return V.require("OverworldBattle").battle()
+ end)
+ if not (ok and battle) then return end
+ local g = game()
+ if not (g.stack and g.stack:top() == battle) then return end
+ if battle.phase ~= "menu" then return end
+ if battle.safari then return end -- the safari menu is already a
+ -- catch menu; its BALL row enters
+ if battle.dramaticShapeDeclined then return end
+ if not LetsGo.wantsMinigame(battle) then return end
+ local ball = CatchThrow.pickBall()
+ if not ball then return end
+ CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
+ fullWild = LetsGo.fullWild(battle) })
+end
+
+-- ------- per frame, from the voxel pipeline's update hook
+--
+-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
+-- frame computes is the ball the scene render a moment later draws.
+function LetsGo.update(dt)
+ applyPrices()
+ CatchThrow.update(dt)
+ autoEnter()
+end
+
+-- ------- install: the two throw seams, the hooks, the input
+--
+-- Installed from main.lua AFTER every other input seam, so the capture's
+-- pointer wraps sit outside them all while it aims.
+local installed = false
+
+function LetsGo.install()
+ if installed then return end
+ installed = true
+
+ local mod = V.mod
+ local BattleState = require("src.battle.BattleState")
+
+ if not BattleState.dramaticShapeLetsGoHook then
+ -- The bag's ball route: BagMenu has already consumed the ball and
+ -- closed itself when this is called, so a session here owns a paid
+ -- ball (cancel refunds it). Vanilla path untouched whenever the
+ -- minigame cannot or should not run.
+ local innerThrow = BattleState.throwBall
+ function BattleState:throwBall(ball)
+ if LetsGo.wantsMinigame(self) then
+ if CatchThrow.begin(self, ball, {
+ consumed = true, fullWild = LetsGo.fullWild(self),
+ }) then return end
+ end
+ return innerThrow(self, ball)
+ end
+
+ -- The safari menu's BALL row: same interception, safari flavour --
+ -- the ball count and the flee check belong to the safari turn, and
+ -- CatchThrow hands back to safariEnemyTurn on a failure.
+ local innerSafari = BattleState.safariAction
+ function BattleState:safariAction(choice)
+ if choice == "ball" and LetsGo.wantsMinigame(self)
+ and self.safari and self.safari.balls > 0 then
+ if CatchThrow.begin(self, "SAFARI_BALL",
+ { consumed = false, safari = true }) then
+ return
+ end
+ end
+ return innerSafari(self, choice)
+ end
+
+ BattleState.dramaticShapeLetsGoHook = true
+ end
+
+ -- a catch pays experience under FULL, exactly as a knockout would
+ mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
+ if LetsGo.mode() == "full" and ctx and ctx.battle
+ and ctx.battle.kind == "wild" then
+ return true
+ end
+ return next_(ctx)
+ end)
+
+ -- ------- the Let's Go distribution: every healthy party member, in full
+ --
+ -- The engine's own rule is that only the Pokemon that FOUGHT are paid,
+ -- and they split one award between them; EXP.ALL exists to soften that.
+ -- Let's Go deletes the whole arrangement -- everybody gains from
+ -- everything, which is why that game ships no EXP.ALL at all -- and
+ -- each one is measured against its OWN level, so the low member of a
+ -- party pulls several times what the high one does from the same
+ -- knockout.
+ --
+ -- Two ways in. A CATCH arrives with a bonus stack attached (throw
+ -- grade, first ball, new species, combo) which `expCtx` carries. A
+ -- KNOCKOUT under FULL takes the same distribution with no stack --
+ -- those bonuses are rewards for the throw, and there was no throw.
+ --
+ -- Everything else -- CATCH ONLY, the row switched off, another mod's
+ -- battle -- falls through to the engine's own split untouched.
+ local function payParty(ctx, mult)
+ granting = { mult = mult }
+ local okAward, err = pcall(function()
+ for _, mon in ipairs(ctx.battle.game.save.party) do
+ if mon.hp > 0 then ctx.applyShare(mon, 1, true) end
+ end
+ end)
+ granting = nil
+ if not okAward then error(err, 0) end
+ end
+
+ mod.hooks:wrap("battle.exp_award", function(next_, ctx)
+ local cc = expCtx
+ if cc and ctx and ctx.battle == cc.battle then
+ expCtx = nil
+ return payParty(ctx, cc.mult)
+ end
+ if LetsGo.mode() == "full" and ctx and ctx.battle then
+ return payParty(ctx, 1)
+ end
+ return next_(ctx)
+ end)
+
+ -- and the amount, per receiving mon, while that loop runs
+ mod.hooks:wrap("exp.gain", function(next_, c)
+ if not granting then return next_(c) end
+ return scaledGain(c, granting.mult)
+ end)
+
+ -- ------- FULL owns a wild encounter from its first frame
+ --
+ -- The engine's intro ends by sending the player's Pokemon out -- the
+ -- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
+ -- Go wild has no player Pokemon in it at all. The send-out is exactly
+ -- the LAST SIX rows of the intro queue when this event fires (built in
+ -- BattleState's start, gated `not safari and not demo`), so they are
+ -- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
+ -- if a future engine moves them: the veil still hides the visuals, the
+ -- engine just narrates a send-out that is not shown.
+ --
+ -- Stripping them leaves showPlayerBack TRUE for the whole battle, which
+ -- is the flag the engine's own HUD path reads as "no player HUD" -- the
+ -- player's side vanishes from the readout for free.
+ --
+ -- The veil goes up in the same breath: the capture table, installed
+ -- before any session exists, so the whole encounter -- wipe, "Wild X
+ -- appeared!", every beat between throws -- plays from the held head-on
+ -- seat with the player's side out of the shot.
+ mod.events:on("battle.started", function(payload)
+ local b = payload and payload.battle
+ if not (b and LetsGo.fullWild(b)) then return end
+ if not (Voxel3D.available() and not vrOn()) then return end
+ if not CatchThrow.pickBall() then return end
+ local q = b.queue
+ local n = q and #q or 0
+ if n >= 6 and type(q[n]) == "table" and q[n].fn
+ and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
+ and q[n - 2] and q[n - 2].text
+ and q[n - 3] and q[n - 3].fn
+ and q[n - 4] and q[n - 4].wait
+ and q[n - 5] and q[n - 5].fn then
+ for _ = 1, 6 do table.remove(q) end
+ end
+ pcall(CatchThrow.veil, b)
+ end)
+
+ -- a battle ending sweeps everything: the capture epilogue, the veil, a
+ -- session a script tore down, and the exp context if the payout never
+ -- fired
+ mod.events:on("battle.ended", function()
+ expCtx = nil
+ pcall(CatchThrow.onBattleEnded)
+ end)
+
+ CatchThrow.installInput()
+end
+
+return LetsGo
diff --git a/lib/Mat4.lua b/lib/Mat4.lua
index fe0c72e..d2486d9 100644
--- a/lib/Mat4.lua
+++ b/lib/Mat4.lua
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
0, 0, 0, 1 }
end
+function Mat4.rotateZ(a)
+ local c, s = math.cos(a), math.sin(a)
+ return { c, -s, 0, 0,
+ s, c, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1 }
+end
+
-- The rotation a unit quaternion describes, row-major. The VR rig is what
-- needs it: an OpenXR eye pose arrives as position + orientation
-- quaternion, and both the eye's transform and its inverse (the view) are
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index 7d167df..a496760 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -1,1527 +1,1582 @@
--- 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
+-- 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
+ -- a capture session aiming has no text to put in the box and takes it
+ -- off the frame (see the drawTextArea wrap): no box, no glass under it
+ local cap = BattleScene.capture
+ if cap and cap.hideTextBox 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.
+ -- A LET'S GO capture session holds it too: the throw is aimed in this
+ -- exact framing, and a camera that moved under a ball in flight would
+ -- bend where the flick was pointed after the fact. (The session also
+ -- sets BattleCam.still, which is what stops the drift -- see
+ -- CatchThrow.begin.)
+ BattleCam.steerable = not OverworldBattle.backPinned()
+ and not BattleScene.capture
+ -- 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 stag